Initial commit
This commit is contained in:
commit
fdccda93e1
51
.cursorrules
Normal file
51
.cursorrules
Normal file
@ -0,0 +1,51 @@
|
||||
## CodeIgniter 4 Project Rules
|
||||
|
||||
### Project Context
|
||||
You are an expert PHP developer specializing in CodeIgniter 4(latest version). This is an Chart board.
|
||||
|
||||
### Coding Standards
|
||||
- **Framework:** CodeIgniter 4.x
|
||||
- **Architecture:** MVC (Models in `app/Models`, Controllers in `app/Controllers`, Views in `app/Views`).
|
||||
- **Database:** Use CI4 Query Builder or Entities for database interactions. No raw SQL unless specified.
|
||||
- **Naming:**
|
||||
- Controllers: PascalCase (e.g., `UserController.php`)
|
||||
- Models: PascalCase (e.g., `UserModel.php`)
|
||||
- Views: snake_case (e.g., `add_user.php`)
|
||||
|
||||
### Best Practices
|
||||
- **Routes:** Always check `app/Config/Routes.php` before suggesting new URLs. Use named routes where possible.
|
||||
- **Validation:** Use CI4's built-in validation service in Controllers or Model `$validationRules`.
|
||||
- **Security:** Always use `csrf_field()` in forms and ensure data is escaped using the Query Builder.
|
||||
- **Entities:** Prefer using Entities (`app/Entities`) for mapping database rows to objects to keep business logic out of Models.
|
||||
|
||||
### Common Commands
|
||||
- When asked to create a file, suggest using PHP Spark: `php spark make:controller Name`, `php spark make:model Name`, `php spark make:migration Name`.
|
||||
|
||||
### envirnment file
|
||||
- keep base_url , database credentials , jwt secret , other secret ... inside .env file
|
||||
|
||||
### view
|
||||
- dont't rewrite header , footer code in every view file keep that seperatly .
|
||||
- user base_url from .env file
|
||||
- Refer the UI wire frame files inside (/public/ui-sample-html-asset/) folder
|
||||
|
||||
### SECURITY focus
|
||||
1 SQL Injection
|
||||
2 HTML XSS script iframe injuction
|
||||
3 csrf
|
||||
4 Stored Cross Site Scripting (XSS)
|
||||
5 Role based Access Control
|
||||
6 Improper Input Validation
|
||||
7 Handle OPTIONS Method
|
||||
8 Basic CI4 validation for all form
|
||||
9 Input sanitizer (get,post,put)
|
||||
|
||||
|
||||
### DB connectivity details
|
||||
database.default.hostname = localhost
|
||||
database.default.database = chartboard
|
||||
database.default.username = root
|
||||
database.default.password =
|
||||
database.default.DBDriver = MySQLi
|
||||
# database.sql file attached here in same location
|
||||
|
||||
126
.gitignore
vendored
Normal file
126
.gitignore
vendored
Normal file
@ -0,0 +1,126 @@
|
||||
#-------------------------
|
||||
# Operating Specific Junk Files
|
||||
#-------------------------
|
||||
|
||||
# OS X
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# OS X Thumbnails
|
||||
._*
|
||||
|
||||
# Windows image file caches
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Linux
|
||||
*~
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
#-------------------------
|
||||
# Environment Files
|
||||
#-------------------------
|
||||
# These should never be under version control,
|
||||
# as it poses a security risk.
|
||||
.env
|
||||
.vagrant
|
||||
Vagrantfile
|
||||
|
||||
#-------------------------
|
||||
# Temporary Files
|
||||
#-------------------------
|
||||
writable/cache/*
|
||||
!writable/cache/index.html
|
||||
|
||||
writable/logs/*
|
||||
!writable/logs/index.html
|
||||
|
||||
writable/session/*
|
||||
!writable/session/index.html
|
||||
|
||||
writable/uploads/*
|
||||
!writable/uploads/index.html
|
||||
|
||||
writable/debugbar/*
|
||||
!writable/debugbar/index.html
|
||||
|
||||
php_errors.log
|
||||
|
||||
#-------------------------
|
||||
# User Guide Temp Files
|
||||
#-------------------------
|
||||
user_guide_src/build/*
|
||||
user_guide_src/cilexer/build/*
|
||||
user_guide_src/cilexer/dist/*
|
||||
user_guide_src/cilexer/pycilexer.egg-info/*
|
||||
|
||||
#-------------------------
|
||||
# Test Files
|
||||
#-------------------------
|
||||
tests/coverage*
|
||||
|
||||
# Don't save phpunit under version control.
|
||||
phpunit
|
||||
|
||||
#-------------------------
|
||||
# Composer
|
||||
#-------------------------
|
||||
vendor/
|
||||
|
||||
#-------------------------
|
||||
# IDE / Development Files
|
||||
#-------------------------
|
||||
|
||||
# Modules Testing
|
||||
_modules/*
|
||||
|
||||
# phpenv local config
|
||||
.php-version
|
||||
|
||||
# Jetbrains editors (PHPStorm, etc)
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# NetBeans
|
||||
/nbproject/
|
||||
/build/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/nbactions.xml
|
||||
/nb-configuration.xml
|
||||
/.nb-gradle/
|
||||
|
||||
# Sublime Text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
.phpintel
|
||||
/api/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
/results/
|
||||
/phpunit*.xml
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2019 British Columbia Institute of Technology
|
||||
Copyright (c) 2019-present CodeIgniter Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
541
README.md
Normal file
541
README.md
Normal file
@ -0,0 +1,541 @@
|
||||
# 📊 Chart-Board
|
||||
|
||||
> A self-hosted, open-source data visualization and dashboard platform built with CodeIgniter 4 (CI4). Connect your databases and APIs, build stunning dashboards, and share insights with your team — all from a clean, modern interface.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Features](#features)
|
||||
3. [Tech Stack](#tech-stack)
|
||||
4. [System Requirements](#system-requirements)
|
||||
5. [Installation](#installation)
|
||||
6. [Configuration](#configuration)
|
||||
7. [Project Structure](#project-structure)
|
||||
8. [UI typography (shell alignment)](#ui-typography-shell-alignment)
|
||||
9. [Modules & Functionality](#modules--functionality)
|
||||
- [Authentication & User Management](#1-authentication--user-management)
|
||||
- [Workspace Management](#2-workspace-management)
|
||||
- [Data Source Connections](#3-data-source-connections)
|
||||
- [Query Builder](#4-query-builder)
|
||||
- [Chart Builder](#5-chart-builder)
|
||||
- [Dashboard Builder](#6-dashboard-builder)
|
||||
- [API Data Source Support](#7-api-data-source-support)
|
||||
- [Alerts & Notifications](#8-alerts--notifications)
|
||||
- [Sharing & Embedding](#9-sharing--embedding)
|
||||
- [Audit Logs](#10-audit-logs)
|
||||
10. [Supported Chart Types](#supported-chart-types)
|
||||
11. [Supported Data Sources](#supported-data-sources)
|
||||
12. [REST API Reference](#rest-api-reference)
|
||||
13. [Roles & Permissions](#roles--permissions)
|
||||
14. [Security Considerations](#security-considerations)
|
||||
15. [Roadmap](#roadmap)
|
||||
16. [Contributing](#contributing)
|
||||
17. [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Chart-Board** is a lightweight, powerful, and extensible analytics dashboard platform. It is designed for developers, analysts, and teams who need to visualize data from multiple sources — databases, REST APIs, CSV files — without writing front-end code from scratch.
|
||||
|
||||
Inspired by tools like Metabase and Chartbrew, Chart-Board is tailored for teams who prefer a self-hosted solution with full control over their data and infrastructure, built on the familiar and lightweight **CodeIgniter 4** PHP framework.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🔌 **Multi-source Connections** — MySQL, PostgreSQL, MongoDB, REST APIs, CSV uploads
|
||||
- 🧱 **Drag-and-Drop Dashboard Builder** — Arrange, resize, and compose charts freely
|
||||
- 📈 **Rich Chart Types** — Bar, Line, Pie, Donut, Area, Scatter, Table, KPI Cards, Funnel, Gauge
|
||||
- 🔍 **Visual Query Builder** — Build queries without SQL knowledge; raw SQL mode also supported
|
||||
- 👥 **Team Workspaces** — Organize dashboards by team or project
|
||||
- 🔐 **Role-Based Access Control** — Admin, Editor, Viewer roles with fine-grained permissions
|
||||
- 🔔 **Alerts & Notifications** — Threshold-based alerts via Email and Slack Webhook
|
||||
- 🔗 **Public Sharing & Embedding** — Share dashboards via public URL or embed via iframe
|
||||
- 🕐 **Scheduled Refresh** — Auto-refresh dashboards at set intervals
|
||||
- 📋 **Audit Logs** — Track all user actions across workspaces
|
||||
- 🌙 **Dark Mode** — Built-in dark/light theme toggle
|
||||
- 📤 **Export** — Export charts as PNG/SVG; export data as CSV/Excel
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|--------------|--------------------------------------|
|
||||
| Backend | PHP 8.1+, CodeIgniter 4 |
|
||||
| Frontend | HTML5, CSS3, Bootstrap 5, Alpine.js |
|
||||
| Charts | ApexCharts.js / Chart.js |
|
||||
| Database | MySQL 8.0+ |
|
||||
| Query Engine | Custom CI4 Query Builder + Raw SQL |
|
||||
| Auth | CI4 Session-based + API Token Auth |
|
||||
| Cache | CI4 File/Redis Cache |
|
||||
| Scheduler | CI4 Task Scheduler (Cron) |
|
||||
| Storage | Local / AWS S3 (for exports/uploads) |
|
||||
|
||||
---
|
||||
|
||||
## System Requirements
|
||||
|
||||
- PHP >= 8.1 with extensions: `intl`, `mbstring`, `curl`, `pdo`, `pdo_mysql`, `json`, `xml`
|
||||
- MySQL >= 8.0
|
||||
- Composer >= 2.x
|
||||
- Node.js >= 18.x (for front-end asset compilation, optional)
|
||||
- Web Server: Apache 2.4+ or Nginx 1.18+
|
||||
- Minimum 512MB RAM (1GB+ recommended for production)
|
||||
- Disk Space: 500MB minimum
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/yourorg/chart-board.git
|
||||
cd chart-board
|
||||
|
||||
# 2. Install PHP dependencies
|
||||
composer install
|
||||
|
||||
# 3. Copy and configure environment file
|
||||
cp env .env
|
||||
# Edit .env with your database credentials and app settings
|
||||
|
||||
# 4. Run database migrations
|
||||
php spark migrate
|
||||
|
||||
# 5. Seed initial data (admin user, default roles)
|
||||
php spark db:seed InitialSeeder
|
||||
|
||||
# 6. (Optional) Install front-end dependencies
|
||||
npm install && npm run build
|
||||
|
||||
# 7. Set folder permissions
|
||||
chmod -R 777 writable/
|
||||
|
||||
# 8. Start the development server
|
||||
php spark serve
|
||||
```
|
||||
|
||||
Visit `http://localhost:8080` to access Chart-Board.
|
||||
|
||||
Default admin credentials (change after first login):
|
||||
- **Email:** `admin@chartboard.local`
|
||||
- **Password:** `Admin@1234`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Key settings in `.env`:
|
||||
|
||||
```ini
|
||||
# Application
|
||||
app.baseURL = 'http://localhost:8080'
|
||||
app.name = 'Chart-Board'
|
||||
|
||||
# Database
|
||||
database.default.hostname = localhost
|
||||
database.default.database = chartboard
|
||||
database.default.username = root
|
||||
database.default.password = secret
|
||||
database.default.DBDriver = MySQLi
|
||||
|
||||
# Cache (file or redis)
|
||||
cache.handler = file
|
||||
|
||||
# Mail (for alerts)
|
||||
email.fromEmail = noreply@chartboard.local
|
||||
email.fromName = Chart-Board
|
||||
email.SMTPHost = smtp.mailtrap.io
|
||||
email.SMTPPort = 587
|
||||
|
||||
# Encryption key (generate with: php spark key:generate)
|
||||
encryption.key = hex2bin:your_generated_key_here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
chart-board/
|
||||
├── app/
|
||||
│ ├── Config/ # CI4 configuration files
|
||||
│ ├── Controllers/
|
||||
│ │ ├── Auth/ # Login, Register, Password Reset
|
||||
│ │ ├── Api/ # REST API controllers
|
||||
│ │ ├── Dashboard/ # Dashboard CRUD
|
||||
│ │ ├── Chart/ # Chart CRUD
|
||||
│ │ ├── DataSource/ # Connection management
|
||||
│ │ ├── Query/ # Query builder & executor
|
||||
│ │ ├── Workspace/ # Workspace management
|
||||
│ │ └── Admin/ # Admin panel
|
||||
│ ├── Models/ # CI4 Model classes
|
||||
│ ├── Views/
|
||||
│ │ ├── auth/
|
||||
│ │ ├── dashboard/
|
||||
│ │ ├── chart/
|
||||
│ │ ├── datasource/
|
||||
│ │ ├── workspace/
|
||||
│ │ └── layouts/
|
||||
│ ├── Libraries/
|
||||
│ │ ├── QueryEngine.php # Runs queries against data sources
|
||||
│ │ ├── ChartRenderer.php # Formats data for chart types
|
||||
│ │ ├── ApiConnector.php # REST API data source handler
|
||||
│ │ └── AlertEngine.php # Threshold checking & notifications
|
||||
│ ├── Filters/ # Auth, RBAC, API token filters
|
||||
│ └── Database/
|
||||
│ ├── Migrations/ # All migration files
|
||||
│ └── Seeds/ # Seeders
|
||||
├── public/ # Web root
|
||||
│ ├── assets/
|
||||
│ │ ├── css/
|
||||
│ │ ├── js/
|
||||
│ │ └── images/
|
||||
├── writable/ # Logs, cache, uploads
|
||||
├── tests/ # PHPUnit tests
|
||||
├── .env
|
||||
├── composer.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI typography (shell alignment)
|
||||
|
||||
The main chrome uses a compact type scale so navigation stays scannable:
|
||||
|
||||
| Token | Variable | Size | Where it appears |
|
||||
|-------|-----------|------|------------------|
|
||||
| Top navigation links | `--cb-font-topnav` | **14px** | Header main menu (`.cb-top-main-link`) |
|
||||
| Sidebar & dense UI | `--cb-font-sidebar` | **13px** | Left menu (`.cb-nav-link`), default body text in main content |
|
||||
|
||||
These variables live in `public/assets/css/variables.css`. **All authenticated screens** wrapped in `<main class="cb-page">` (workspaces, data sources, queries list/view, dashboard, profile, admin users, etc.) inherit this scale: page body at **13px**, full-size inputs/buttons and subsection titles (`h2.h5`, `.h6`) at **14px**, matching the shell. Login/register/forgot-password use `.cb-auth-body` on the auth card for the same rules.
|
||||
|
||||
The query builder adds `public/assets/css/query-builder.css` under `.qb-root` (max-width + preview/SQL-specific styles only; base type comes from `.cb-page`).
|
||||
|
||||
Page titles use Bootstrap’s `h1` + `.h4` pattern with **font-weight 700** (not `display-*` sizes).
|
||||
|
||||
---
|
||||
|
||||
## Modules & Functionality
|
||||
|
||||
### 1. Authentication & User Management
|
||||
|
||||
- Registration with email verification
|
||||
- Login with session-based authentication
|
||||
- Password reset via email token
|
||||
- API Token generation for programmatic access
|
||||
- Profile management (name, avatar, password change)
|
||||
- Super Admin panel to manage all users
|
||||
|
||||
### 2. Workspace Management
|
||||
|
||||
A **Workspace** is the top-level organizational unit (similar to a team or project).
|
||||
|
||||
- Create and manage multiple workspaces
|
||||
- Invite users to a workspace with a specific role (Admin / Editor / Viewer)
|
||||
- Each workspace has its own data sources, charts, and dashboards
|
||||
- Workspace-level settings: name, logo, timezone, default refresh interval
|
||||
|
||||
### 3. Data Source Connections
|
||||
|
||||
Connect to external data sources. All credentials are encrypted at rest.
|
||||
|
||||
**Supported types:**
|
||||
|
||||
| Type | Details |
|
||||
|--------------|--------------------------------------|
|
||||
| MySQL | Host, Port, DB name, User, Password |
|
||||
| PostgreSQL | Host, Port, DB name, User, Password |
|
||||
| MongoDB | Connection URI |
|
||||
| REST API | URL, Method, Headers, Auth (Bearer / Basic / API Key) |
|
||||
| CSV Upload | Upload CSV files as static datasets |
|
||||
|
||||
- Test connection before saving
|
||||
- Multiple data sources per workspace
|
||||
- Connection health status indicator
|
||||
|
||||
### 4. Query Builder
|
||||
|
||||
Two modes for fetching data from a data source:
|
||||
|
||||
**Visual Builder (No-Code):**
|
||||
- Select table → choose columns → apply filters → set grouping & sorting
|
||||
- Filter conditions: equals, not equals, contains, greater than, less than, between, is null
|
||||
- Aggregate functions: COUNT, SUM, AVG, MIN, MAX
|
||||
- Preview results in a table before saving
|
||||
|
||||
**Raw SQL Mode:**
|
||||
- Write custom SQL with syntax highlighting (CodeMirror)
|
||||
- Schema browser sidebar (tables & columns)
|
||||
- Query result preview (limited to 100 rows)
|
||||
- Save queries for reuse
|
||||
|
||||
**For API Sources:**
|
||||
- Define endpoint URL (supports dynamic date variables like `{{today}}`, `{{last_30_days}}`)
|
||||
- Map JSON response path to extract data array
|
||||
- Define field aliases for chart use
|
||||
|
||||
### Query Variables
|
||||
|
||||
Query Variables allow chart creators to define named placeholders inside SQL or API queries that end users can fill in at dashboard view time — without touching the query itself.
|
||||
|
||||
**Syntax:** Use double curly braces `{{ variable_name }}` anywhere in a SQL query.
|
||||
```sql
|
||||
SELECT DATE(created_at) AS date, SUM(amount) AS revenue
|
||||
FROM orders
|
||||
WHERE
|
||||
created_at BETWEEN '{{ start_date }}' AND '{{ end_date }}'
|
||||
AND status = '{{ order_status }}'
|
||||
AND region = '{{ region }}'
|
||||
GROUP BY DATE(created_at)
|
||||
```
|
||||
|
||||
**Variable Types:**
|
||||
|
||||
| Type | Input Widget | Example |
|
||||
|------|-------------|---------|
|
||||
| `text` | Free text input | customer name, SKU |
|
||||
| `number` | Number input | threshold, limit |
|
||||
| `date` | Date picker | start date, end date |
|
||||
| `date_range` | Dual date picker | from → to |
|
||||
| `select` | Dropdown (options defined by creator) | status, region, category |
|
||||
| `multi_select` | Multi-select dropdown | multiple statuses |
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Creator writes query with `{{ variable_name }}` placeholders
|
||||
2. Chart-Board auto-detects all variables and prompts the creator to configure each one (type, label, default value, options for dropdowns)
|
||||
3. Variable config is saved in `charts.display_config` as JSON
|
||||
4. On dashboard view, users see a filter widget per variable above the chart
|
||||
5. On change, Chart-Board re-runs the query with substituted safe-parameterized values (never raw string injection)
|
||||
|
||||
**Built-in System Variables (no config needed):**
|
||||
|
||||
| Variable | Resolves To |
|
||||
|----------|------------|
|
||||
| `{{ today }}` | Current date `YYYY-MM-DD` |
|
||||
| `{{ now }}` | Current datetime `YYYY-MM-DD HH:MM:SS` |
|
||||
| `{{ yesterday }}` | Yesterday's date |
|
||||
| `{{ last_7_days_start }}` | 7 days ago |
|
||||
| `{{ last_30_days_start }}` | 30 days ago |
|
||||
| `{{ last_90_days_start }}` | 90 days ago |
|
||||
| `{{ this_month_start }}` | First day of current month |
|
||||
| `{{ this_year_start }}` | First day of current year |
|
||||
| `{{ current_user_id }}` | Logged-in user's ID |
|
||||
| `{{ current_workspace_id }}` | Active workspace ID |
|
||||
|
||||
**Security:** All user-supplied variable values are passed as PDO prepared statement bindings — never interpolated directly into SQL strings.
|
||||
|
||||
### 5. Chart Builder
|
||||
|
||||
Build charts from a saved query or direct query.
|
||||
|
||||
**Steps:**
|
||||
1. Select Data Source + enter/select query
|
||||
2. Choose Chart Type
|
||||
3. Map X-axis, Y-axis, group-by fields
|
||||
4. Configure labels, colors, legend, tooltips
|
||||
5. Set refresh interval (manual / 1 min / 5 min / 15 min / 1 hour / 1 day)
|
||||
6. Save chart with a name and description
|
||||
|
||||
**Chart Settings:**
|
||||
- Title & subtitle
|
||||
- Color palette (preset or custom)
|
||||
- Show/hide legend
|
||||
- Show/hide data labels
|
||||
- Custom Y-axis min/max
|
||||
- Number formatting (currency, percentage, decimal places)
|
||||
- Date format for time-series X-axis
|
||||
|
||||
### 6. Dashboard Builder
|
||||
|
||||
A Dashboard is a collection of charts arranged in a grid layout.
|
||||
|
||||
- Drag-and-drop chart placement
|
||||
- Resize charts (1×1 to 4×2 grid units)
|
||||
- Add text/markdown widgets (for annotations and section titles)
|
||||
- Add image widgets (logo, banner)
|
||||
- Add filter widgets (date range picker, dropdown) that apply across all charts
|
||||
- Dashboard-level refresh interval
|
||||
- Fullscreen mode
|
||||
|
||||
### 7. API Data Source Support
|
||||
|
||||
A key differentiator — pull data directly from REST APIs:
|
||||
|
||||
- GET / POST support
|
||||
- Auth modes: None, Bearer Token, Basic Auth, API Key (header or query param)
|
||||
- Static or dynamic headers
|
||||
- JSON path extraction (`data.results[*].value`)
|
||||
- Pagination support (offset/cursor-based)
|
||||
- Schedule automatic sync (cache API response for N minutes)
|
||||
- Supports webhook-push mode (Chart-Board provides a unique URL to receive data)
|
||||
|
||||
### 8. Alerts & Notifications
|
||||
|
||||
Define threshold-based alerts on any chart metric.
|
||||
|
||||
- UI: **Alerts** in the top nav (`/alert`) — create, edit, mute (1h / 4h / 24h), history, delete
|
||||
- Conditions: `gt`, `lt`, `eq`, `gte`, `lte` on the first row of the chart’s query (numeric column)
|
||||
- Check interval: per-alert minutes (`check_interval`); cron runs `php spark alert:check` (intended every minute)
|
||||
- Channels: Email (comma-separated addresses) and Slack incoming webhook
|
||||
- Duplicate notifications suppressed if the same alert fired within the last 5 minutes
|
||||
- History stored in `alert_history`; recent triggers shown on the alerts index; badge on **Alerts** for sends in the last hour
|
||||
|
||||
### 9. Sharing & Embedding
|
||||
|
||||
- **Public link** — `GET /share/{token}` (no auth). Optional password (`POST /share/{token}/unlock`) and expiry. Records in `shared_links`; view count incremented once per session per link.
|
||||
- **Create / revoke** — Authenticated: `POST /sharing/generate`, `POST /sharing/revoke/{id}`, list: `GET /sharing/links?type=dashboard|chart&resource_id=…`. Share modal on dashboard view, chart list, and chart editor (QR via external API in modal).
|
||||
- **Embed** — `?embed=1` on the same URL; response drops `X-Frame-Options` and sets CSP `frame-ancestors *`. Embed tab in the share modal with size presets and iframe HTML.
|
||||
- **Chart data for public dashboards** — `POST /share/{token}/chart/{id}/data` (same variable JSON as authenticated charts; no CSRF when token is valid).
|
||||
- **Export** — Chart editor: PNG (Apex `dataURI` + FileSaver.js, client-side). Server: `GET /chart/{id}/export?format=csv|excel` (PhpSpreadsheet for `.xlsx`); logged in `chart_exports` for CSV/Excel.
|
||||
|
||||
### 10. Audit Logs
|
||||
|
||||
Track all significant user actions:
|
||||
|
||||
- User login / logout
|
||||
- Data source created / updated / deleted
|
||||
- Chart created / updated / deleted
|
||||
- Dashboard shared / unshared
|
||||
- User role changes
|
||||
- Alert triggered
|
||||
|
||||
Logs are viewable by workspace admins and filterable by user, action type, and date range.
|
||||
|
||||
---
|
||||
|
||||
## Supported Chart Types
|
||||
|
||||
| Chart Type | Description |
|
||||
|---------------|--------------------------------------------------|
|
||||
| Bar Chart | Vertical or horizontal bar comparison |
|
||||
| Line Chart | Trend over time |
|
||||
| Area Chart | Filled line chart for volume trends |
|
||||
| Pie Chart | Proportional distribution |
|
||||
| Donut Chart | Pie with center label (good for KPIs) |
|
||||
| Scatter Plot | Correlation between two numeric variables |
|
||||
| Data Table | Paginated tabular display of query results |
|
||||
| KPI Card | Single metric with optional trend indicator |
|
||||
| Funnel Chart | Step-by-step conversion visualization |
|
||||
| Gauge Chart | Progress toward a goal (0–100%) |
|
||||
| Heatmap | Matrix of values colored by intensity |
|
||||
| Combo Chart | Bar + Line overlaid on same axes |
|
||||
|
||||
---
|
||||
|
||||
## Supported Data Sources
|
||||
|
||||
| Source | Connection Type | Status |
|
||||
|-------------|---------------------|----------|
|
||||
| MySQL | Direct TCP | ✅ v1.0 |
|
||||
| PostgreSQL | Direct TCP | ✅ v1.0 |
|
||||
| MongoDB | URI | ✅ v1.0 |
|
||||
| REST API | HTTP/HTTPS | ✅ v1.0 |
|
||||
| CSV Upload | File Upload | ✅ v1.0 |
|
||||
| SQLite | File Path | 🔜 v1.1 |
|
||||
| BigQuery | OAuth / Service Key | 🔜 v1.2 |
|
||||
| Snowflake | JDBC-style DSN | 🔜 v1.2 |
|
||||
| Google Sheets | OAuth | 🔜 v1.2 |
|
||||
|
||||
---
|
||||
|
||||
## REST API Reference
|
||||
|
||||
Chart-Board exposes a REST API for programmatic access (authenticated via API token).
|
||||
|
||||
**Base URL:** `https://yourdomain.com/api/v1`
|
||||
|
||||
**Authentication Header:**
|
||||
```
|
||||
Authorization: Bearer <your_api_token>
|
||||
```
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|--------------------------------------|-------------------------------|
|
||||
| GET | `/workspaces` | List all workspaces |
|
||||
| POST | `/workspaces` | Create a workspace |
|
||||
| GET | `/workspaces/{id}/datasources` | List data sources |
|
||||
| POST | `/workspaces/{id}/datasources` | Add a data source |
|
||||
| GET | `/workspaces/{id}/charts` | List charts |
|
||||
| POST | `/workspaces/{id}/charts` | Create a chart |
|
||||
| GET | `/workspaces/{id}/dashboards` | List dashboards |
|
||||
| POST | `/workspaces/{id}/dashboards` | Create a dashboard |
|
||||
| GET | `/charts/{id}/data` | Fetch chart data (JSON) |
|
||||
| POST | `/datasources/{id}/query` | Run a query |
|
||||
| GET | `/alerts` | List alerts |
|
||||
|
||||
---
|
||||
|
||||
## Roles & Permissions
|
||||
|
||||
| Permission | Super Admin | Workspace Admin | Editor | Viewer |
|
||||
|-------------------------|:-----------:|:---------------:|:------:|:------:|
|
||||
| Manage users (global) | ✅ | ❌ | ❌ | ❌ |
|
||||
| Manage workspaces | ✅ | ✅ | ❌ | ❌ |
|
||||
| Invite members | ✅ | ✅ | ❌ | ❌ |
|
||||
| Manage data sources | ✅ | ✅ | ✅ | ❌ |
|
||||
| Create/edit charts | ✅ | ✅ | ✅ | ❌ |
|
||||
| Create/edit dashboards | ✅ | ✅ | ✅ | ❌ |
|
||||
| View dashboards | ✅ | ✅ | ✅ | ✅ |
|
||||
| Share dashboards | ✅ | ✅ | ✅ | ❌ |
|
||||
| Manage alerts | ✅ | ✅ | ✅ | ❌ |
|
||||
| View audit logs | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All data source credentials are encrypted using CI4's Encryption library (AES-256)
|
||||
- SQL queries run in read-only mode on connected databases (use a dedicated read-only DB user)
|
||||
- CSRF protection enabled on all forms
|
||||
- XSS filtering on all user inputs
|
||||
- Rate limiting on API endpoints (60 req/min per token)
|
||||
- Public dashboard links support optional password and expiry
|
||||
- HTTP-only, SameSite=Strict session cookies
|
||||
- Content Security Policy (CSP) headers configured
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Version | Planned Features |
|
||||
|---------|-------------------------------------------------------------------------|
|
||||
| v1.0 | Core dashboard, MySQL/PostgreSQL/REST API sources, basic chart types |
|
||||
| v1.1 | SQLite support, improved mobile view, CSV export, chart comments |
|
||||
| v1.2 | BigQuery, Snowflake, Google Sheets connectors |
|
||||
| v1.3 | AI-powered natural language query ("Show me sales last month") |
|
||||
| v1.4 | White-label mode, multi-tenant SaaS mode |
|
||||
| v2.0 | Real-time streaming data (WebSocket), collaborative dashboard editing |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
php spark test
|
||||
|
||||
# Check code style
|
||||
vendor/bin/phpcs --standard=PSR12 app/
|
||||
|
||||
# Fix code style
|
||||
vendor/bin/phpcbf --standard=PSR12 app/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Chart-Board is open-source software licensed under the [MIT License](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ using CodeIgniter 4*
|
||||
621
TASKS.md
Normal file
621
TASKS.md
Normal file
@ -0,0 +1,621 @@
|
||||
# 📋 Chart-Board — Phased Work & Task Breakdown
|
||||
|
||||
> **Project:** Chart-Board (CodeIgniter 4)
|
||||
> **Stack:** PHP 8.1 · CI4 · MySQL 8 · Bootstrap 5 · ApexCharts.js · Alpine.js
|
||||
> **Total Estimated Duration:** ~14 Weeks (Solo) / ~7 Weeks (2-Dev Team)
|
||||
|
||||
---
|
||||
|
||||
## 📌 Legend
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| 🔴 | Blocker — must be done before next task |
|
||||
| 🟡 | Important — high priority |
|
||||
| 🟢 | Normal priority |
|
||||
| ⚙️ | Backend task |
|
||||
| 🎨 | Frontend task |
|
||||
| 🧪 | Testing task |
|
||||
| 📄 | Documentation task |
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 1 — Project Foundation & Setup
|
||||
### Estimated Time: 3–4 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 1.1 Environment & Scaffolding
|
||||
|
||||
- [x] 🔴 ⚙️ Install CodeIgniter 4 via Composer (`composer create-project codeigniter4/appstarter chart-board`)
|
||||
- [x] 🔴 ⚙️ Configure `.env` file — `baseURL`, `database.*`, `CI_ENVIRONMENT`
|
||||
- [X] 🔴 ⚙️ Set up MySQL database `chartboard` with `utf8mb4` charset
|
||||
- [ ] 🔴 ⚙️ Run the full `chartboard.sql` schema to create all 15+ tables
|
||||
- [x] 🟡 ⚙️ Configure `app/Config/Database.php` for MySQL connection
|
||||
- [x] 🟡 ⚙️ Set up `app/Config/App.php` — timezone, base URL, session settings
|
||||
- [x] 🟢 ⚙️ Configure `app/Config/Cache.php` — file-based cache for dev, Redis for prod
|
||||
- [x] 🟢 ⚙️ Configure `app/Config/Email.php` — SMTP settings for alerts/verification
|
||||
- [x] 🟢 ⚙️ Set writable directory permissions (`chmod -R 777 writable/`)
|
||||
- [x] 🟢 📄 Create `.gitignore` — exclude `.env`, `writable/`, `vendor/`
|
||||
|
||||
### 1.2 Front-End Base Setup
|
||||
|
||||
- [x] 🔴 🎨 Integrate Bootstrap 5 via CDN or npm build pipeline
|
||||
- [x] 🔴 🎨 Integrate ApexCharts.js via CDN
|
||||
- [x] 🟡 🎨 Integrate Alpine.js for lightweight reactivity
|
||||
- [x] 🟡 🎨 Create base layout file `app/Views/layouts/main.php` — sidebar + topbar + content slot
|
||||
- [x] 🟡 🎨 Create `app/Views/layouts/auth.php` — centered card layout for login/register
|
||||
- [x] 🟢 🎨 Set up global CSS variables file `public/assets/css/variables.css`
|
||||
- [x] 🟢 🎨 Set up global JS file `public/assets/js/app.js` — sidebar toggle, toast, helpers
|
||||
- [x] 🟢 🎨 Add favicon, logo mark assets to `public/assets/images/`
|
||||
|
||||
### 1.3 CI4 Structure Setup
|
||||
|
||||
- [x] 🟡 ⚙️ Create base `BaseController.php` — set shared data (user session, workspace)
|
||||
- [x] 🟡 ⚙️ Create `app/Config/Routes.php` skeleton — group routes by module
|
||||
- [x] 🟡 ⚙️ Create `AuthFilter.php` — redirect unauthenticated users to login
|
||||
- [x] 🟡 ⚙️ Create `RoleFilter.php` — check workspace role before allowing access
|
||||
- [x] 🟢 ⚙️ Create `ApiAuthFilter.php` — validate `Authorization: Bearer` token for API routes
|
||||
- [x] 🟢 ⚙️ Set up CI4 Encryption service config (`app/Config/Encryption.php`)
|
||||
- [x] 🟢 ⚙️ Generate encryption key with `php spark key:generate`
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 2 — Authentication & User Management
|
||||
### Estimated Time: 4–5 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 2.1 User Model & Migration
|
||||
|
||||
- [x] 🔴 ⚙️ Create `UserModel.php` — CRUD, soft delete, findByEmail, findByApiToken
|
||||
- [x] 🟡 ⚙️ Create `Migration_CreateUsersTable.php` (already in SQL; create CI4 migration file)
|
||||
- [x] 🟢 ⚙️ Create `InitialSeeder.php` — seed default super admin + default workspace
|
||||
|
||||
### 2.2 Registration
|
||||
|
||||
- [x] 🔴 ⚙️ `Auth/RegisterController.php` — show form, validate input, hash password, save user
|
||||
- [x] 🔴 ⚙️ Generate `verify_token`, send verification email on registration
|
||||
- [x] 🟡 🎨 `app/Views/auth/register.php` — registration form with name, email, password, confirm password
|
||||
- [x] 🟡 ⚙️ `Auth/RegisterController::verify()` — handle email verification token link
|
||||
- [x] 🟢 🎨 Show success flash message after registration
|
||||
- [ ] 🟢 🧪 Test: register with valid data, duplicate email, weak password
|
||||
|
||||
### 2.3 Login & Logout
|
||||
|
||||
- [x] 🔴 ⚙️ `Auth/LoginController.php` — validate credentials, check `email_verified`, start session
|
||||
- [x] 🔴 ⚙️ Store user data in CI4 session: `user_id`, `name`, `email`, `role`
|
||||
- [x] 🔴 🎨 `app/Views/auth/login.php` — email + password form, remember me checkbox
|
||||
- [x] 🟡 ⚙️ `Auth/LoginController::logout()` — destroy session, redirect to login
|
||||
- [x] 🟡 ⚙️ Insert record into `user_sessions` on login, delete on logout
|
||||
- [ ] 🟢 🧪 Test: wrong password, unverified email, active session redirect
|
||||
|
||||
### 2.4 Password Reset
|
||||
|
||||
- [x] 🟡 ⚙️ `Auth/PasswordController::forgot()` — generate `reset_token`, set `reset_token_expiry`, send email
|
||||
- [x] 🟡 ⚙️ `Auth/PasswordController::reset()` — validate token, check expiry, update password
|
||||
- [x] 🟡 🎨 `app/Views/auth/forgot.php` — email input form
|
||||
- [x] 🟡 🎨 `app/Views/auth/reset.php` — new password + confirm form
|
||||
- [ ] 🟢 🧪 Test: expired token, already-used token, mismatched passwords
|
||||
|
||||
### 2.5 User Profile
|
||||
|
||||
- [x] 🟡 ⚙️ `ProfileController.php` — show profile, update name/avatar, change password
|
||||
- [x] 🟡 🎨 `app/Views/profile/index.php` — profile card with avatar upload
|
||||
- [x] 🟡 ⚙️ Handle avatar image upload to `writable/uploads/avatars/`
|
||||
- [x] 🟡 ⚙️ `ProfileController::generateApiToken()` — create/rotate personal API token, save hashed
|
||||
- [x] 🟢 🎨 Show/copy API token UI with regenerate button
|
||||
- [ ] 🟢 🧪 Test: avatar upload size limits, password mismatch, token regeneration
|
||||
|
||||
### 2.6 Super Admin — User Management
|
||||
|
||||
- [x] 🟡 ⚙️ `Admin/UserController.php` — list all users, activate/deactivate, change role
|
||||
- [x] 🟡 🎨 `app/Views/admin/users/index.php` — paginated table with search and status filter
|
||||
- [x] 🟢 🎨 `app/Views/admin/users/edit.php` — edit user role and active status
|
||||
- [ ] 🟢 🧪 Test: deactivate user blocks login, role change reflects immediately
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 3 — Workspace Management
|
||||
### Estimated Time: 3–4 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 3.1 Workspace CRUD
|
||||
|
||||
- [x] 🔴 ⚙️ `WorkspaceController.php` — create, read, update, delete workspaces
|
||||
- [x] 🔴 ⚙️ `WorkspaceModel.php` — with soft delete, slug generation, owner filter
|
||||
- [x] 🔴 🎨 `app/Views/workspace/index.php` — workspace list/grid with create button
|
||||
- [x] 🟡 🎨 `app/Views/workspace/create.php` — name, description, timezone, logo upload form
|
||||
- [x] 🟡 🎨 `app/Views/workspace/settings.php` — edit workspace details
|
||||
- [x] 🟡 ⚙️ Auto-generate unique slug from workspace name on creation
|
||||
- [x] 🟡 ⚙️ On workspace creation, auto-insert creator as `workspace_members` with role `admin`
|
||||
- [x] 🟢 ⚙️ Soft delete workspace — cascade to members, data sources, charts, dashboards
|
||||
- [ ] 🟢 🧪 Test: duplicate slug, logo upload, owner-only delete restriction
|
||||
|
||||
### 3.2 Members & Invitations
|
||||
|
||||
- [x] 🔴 ⚙️ `WorkspaceMemberController.php` — list members, change role, remove member
|
||||
- [x] 🔴 ⚙️ `WorkspaceInvitationController.php` — send invite email, accept invite, cancel invite
|
||||
- [x] 🟡 🎨 `app/Views/workspace/members.php` — members table with role dropdown and remove button
|
||||
- [x] 🟡 🎨 `app/Views/workspace/invite.php` — email + role form, pending invites list
|
||||
- [x] 🟡 ⚙️ Generate secure `token` for invite, store in `workspace_invitations`, set expiry (48h)
|
||||
- [x] 🟡 ⚙️ Public route `/invite/{token}` — if user exists log them in; else redirect to register
|
||||
- [x] 🟢 ⚙️ Prevent inviting existing members, prevent duplicate pending invites
|
||||
- [ ] 🟢 🧪 Test: expired token, already-accepted token, role change enforcement
|
||||
|
||||
### 3.3 Workspace Context Switching
|
||||
|
||||
- [x] 🟡 ⚙️ Store `active_workspace_id` in session, set on login/switch
|
||||
- [x] 🟡 🎨 Workspace switcher dropdown in sidebar — list user's workspaces, highlight active
|
||||
- [x] 🟢 ⚙️ Middleware: validate user is member of active workspace on every request
|
||||
- [ ] 🟢 🧪 Test: user with 0 workspaces, switching while on a dashboard page
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 4 — Data Source Connections
|
||||
### Estimated Time: 5–6 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 4.1 Data Source Model & Encryption
|
||||
|
||||
- [x] 🔴 ⚙️ `DataSourceModel.php` — CRUD, filter by workspace, soft delete
|
||||
- [x] 🔴 ⚙️ `Libraries/Encrypter.php` — wrap CI4 Encryption to encrypt/decrypt credentials
|
||||
- [x] 🔴 ⚙️ Encrypt `password`, `api_auth_value` fields before saving; decrypt on retrieval
|
||||
- [ ] 🟢 🧪 Test: encrypted values are not plain text in DB, decryption returns correct value
|
||||
|
||||
### 4.2 Connection UI
|
||||
|
||||
- [x] 🔴 🎨 `app/Views/datasource/index.php` — list all data sources with type icon, status badge
|
||||
- [x] 🔴 🎨 `app/Views/datasource/create.php` — dynamic form (type selector shows/hides fields)
|
||||
- [x] 🟡 🎨 Alpine.js: show MySQL/PostgreSQL fields when DB type selected; show API fields for REST API
|
||||
- [x] 🟡 🎨 `app/Views/datasource/edit.php` — edit form with masked password field
|
||||
- [x] 🟡 🎨 Connection status badge — Untested / Connected (green) / Failed (red)
|
||||
- [x] 🟢 🎨 Delete confirmation modal
|
||||
|
||||
### 4.3 Connection Drivers
|
||||
|
||||
- [x] 🔴 ⚙️ `Libraries/Connectors/MySQLConnector.php` — connect via PDO, run test query `SELECT 1`
|
||||
- [x] 🔴 ⚙️ `Libraries/Connectors/PostgreSQLConnector.php` — connect via PDO pgsql
|
||||
- [x] 🟡 ⚙️ `Libraries/Connectors/MongoDBConnector.php` — connect via MongoDB PHP library URI
|
||||
- [x] 🟡 ⚙️ `Libraries/Connectors/RestApiConnector.php` — cURL GET/POST with auth headers
|
||||
- [x] 🟡 ⚙️ `Libraries/Connectors/CsvConnector.php` — parse uploaded CSV into in-memory array
|
||||
- [x] 🟢 ⚙️ `Libraries/ConnectionFactory.php` — factory to return correct connector by type
|
||||
- [ ] 🟢 🧪 Test: each connector with valid/invalid credentials
|
||||
|
||||
### 4.4 Test Connection Endpoint
|
||||
|
||||
- [x] 🔴 ⚙️ `POST /datasource/test` (AJAX) — instantiate connector, run test, return JSON `{success, message}`
|
||||
- [x] 🔴 🎨 "Test Connection" button with spinner; show success/error inline below button
|
||||
- [x] 🟡 ⚙️ Update `status` and `last_tested_at` in DB after test
|
||||
- [ ] 🟢 🧪 Test: timeout handling (set cURL timeout 10s), wrong host, wrong credentials
|
||||
|
||||
### 4.5 Schema Browser (for Query Builder)
|
||||
|
||||
- [x] 🟡 ⚙️ `GET /datasource/{id}/schema` (AJAX) — return tables list and columns per table as JSON
|
||||
- [ ] 🟡 🎨 Schema sidebar in query builder — collapsible tree: Tables → Columns with types
|
||||
- [x] 🟢 ⚙️ Cache schema response for 5 minutes per data source
|
||||
- [ ] 🟢 🧪 Test: DB with 100+ tables, special characters in column names
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 5 — Query Builder
|
||||
### Estimated Time: 5–6 Days
|
||||
**Completion note:** Core MVP items are implemented (unified create/edit form, three query modes, multi-filter / multi–order-by visual builder, variables for raw + visual, API headers in `api_params`, preview table + execution log, query cache). Open: CodeMirror editor, datasource schema tree in UI, visual aggregates + GROUP BY controls, chart/dashboard variable wiring (Phase 6), and listed tests.
|
||||
|
||||
---
|
||||
|
||||
### 5.1 Saved Query Model
|
||||
|
||||
- [x] 🔴 ⚙️ `SavedQueryModel.php` — CRUD, filter by workspace and data source, soft delete
|
||||
- [ ] 🟢 🧪 Test: save and retrieve query with complex JSON config
|
||||
|
||||
### 5.2 Visual Query Builder (No-Code)
|
||||
|
||||
- [x] 🔴 🎨 Visual mode in `app/Views/query/_form.php` (create/edit) — table name input + comma-separated columns *(no separate `visual.php`; schema dropdown / per-column checkboxes not implemented)*
|
||||
- [ ] 🔴 🎨 Schema-driven table dropdown + column checkboxes with optional aliases
|
||||
- [x] 🟡 🎨 Filter builder — add/remove filter rows: field, operator (=, !=, >, <, LIKE, IS NULL, IS NOT NULL), value
|
||||
- [ ] 🟡 🎨 Aggregate row — apply COUNT/SUM/AVG/MIN/MAX to numeric columns
|
||||
- [ ] 🟡 🎨 GROUP BY selector — multi-select *(backend `QueryBuilder::toSQL()` supports `group_by[]`; no UI yet)*
|
||||
- [x] 🟡 🎨 ORDER BY — multiple sort columns, each with ASC/DESC + add/remove rows
|
||||
- [x] 🟡 🎨 LIMIT input — max rows (default 500); supports `{{variable}}` in visual fields
|
||||
- [x] 🟡 ⚙️ `QueryBuilder::toSQL()` — convert visual config JSON to safe parameterized SQL
|
||||
- [x] 🟢 ⚙️ Prevent destructive keywords: block `DROP`, `DELETE`, `UPDATE`, `INSERT`, `TRUNCATE` in generated SQL
|
||||
- [ ] 🟢 🧪 Test: multi-filter query, aggregate with group by, null filter
|
||||
|
||||
### 5.3 Raw SQL Mode
|
||||
|
||||
- [ ] 🔴 🎨 CodeMirror 6 editor integration — SQL syntax highlighting, auto-complete (table/column names) *(currently plain textarea in `_form.php`)*
|
||||
- [x] 🔴 🎨 Mode selector — Raw SQL / Visual builder / API query
|
||||
- [x] 🟡 ⚙️ SQL safety check before execution — regex/parse to block DDL/DML mutations
|
||||
- [x] 🟡 ⚙️ `QueryController::execute()` — run sanitized SQL on the selected data source, return JSON results
|
||||
- [x] 🟢 ⚙️ Enforce query timeout — kill query after 30 seconds
|
||||
- [ ] 🟢 🧪 Test: malicious SQL injection attempt, timeout simulation, empty result set
|
||||
|
||||
|
||||
### 5.4 Query Variables
|
||||
|
||||
- [x] 🔴 ⚙️ `Libraries/QueryVariableParser.php` — scan query string for `{{ var_name }}` pattern using regex, return list of variable names
|
||||
- [x] 🔴 ⚙️ `Libraries/QueryVariableResolver.php` — resolve system variables (`{{today}}`, `{{now}}`, etc.) and substitute user values via PDO bindings; `resolveTemplateString()` for visual builder identifiers/literals
|
||||
- [x] 🔴 🎨 Variable panel — Raw SQL + Visual builder: detect / configure / test values; hidden for API mode on create/edit
|
||||
- [x] 🔴 🎨 Variable config form per detected variable:
|
||||
- Label (display name shown to end user)
|
||||
- Type: `text` / `number` / `date` / `date_range` / `select` / `multi_select`
|
||||
- Default value
|
||||
- For `select`/`multi_select`: options list (comma-separated or from another query)
|
||||
- Required toggle
|
||||
- [x] 🟡 ⚙️ Persist variable definitions in `query_variables` table (per `saved_query_id`) on save/update *(dashboard/chart `display_config` integration pending Phase 6)*
|
||||
- [ ] 🟡 🎨 Dashboard view — render a variable input widget per variable above each chart that has variables defined:
|
||||
- `text` → `<input type="text">`
|
||||
- `number` → `<input type="number">`
|
||||
- `date` → date picker
|
||||
- `date_range` → dual date range picker
|
||||
- `select` → dropdown
|
||||
- `multi_select` → multi-select dropdown with checkboxes
|
||||
- [ ] 🟡 🎨 On variable value change → re-fetch chart data AJAX with new values, re-render chart without page reload
|
||||
- [ ] 🟡 ⚙️ Global dashboard filter — if multiple charts share a variable with the same name, a single widget controls all of them simultaneously
|
||||
- [x] 🟡 ⚙️ Resolve built-in system variables server-side before query execution (no user input needed for these)
|
||||
- [x] 🟢 🎨 Variable widget in Query Builder preview — show input fields for each detected variable so creator can test values before saving
|
||||
- [x] 🟢 ⚙️ `multi_select` in raw SQL — `QueryVariableResolver::resolve()` expands array values to multiple `?` placeholders (use inside `IN ({{var}})` in SQL)
|
||||
- [ ] 🟢 ⚙️ Public shared dashboards — variable widgets still visible and functional for anonymous viewers
|
||||
- [ ] 🟢 🧪 Test: SQL injection attempt via variable value is blocked by PDO binding; date variable resolves correctly; missing required variable shows validation error; multi-select generates correct `IN` clause
|
||||
|
||||
|
||||
### 5.5 API Query Builder
|
||||
|
||||
- [x] 🟡 🎨 API query form — endpoint URL, JSON path, grouped sections (headers + field map with add/remove rows)
|
||||
- [x] 🟡 🎨 HTTP headers — repeatable rows; persisted in `saved_queries.api_params` as JSON `{ "headers": [...] }`
|
||||
- [x] 🟡 🎨 JSON path input — e.g. `data.results` to extract nested array
|
||||
- [x] 🟡 🎨 Field map — source JSON key → column alias rows
|
||||
- [x] 🟡 ⚙️ `Libraries/ApiConnector::fetch()` — variable substitution on URL, headers, extract by JSON path
|
||||
- [ ] 🟢 🧪 Test: nested JSON path, missing field graceful fallback, invalid URL
|
||||
|
||||
### 5.6 Query Preview & Result Table
|
||||
|
||||
- [x] 🔴 🎨 "Run Query" button — AJAX call, show spinner while loading
|
||||
- [x] 🔴 🎨 Result preview table — first 100 rows, dynamic columns, sortable headers, pagination, CSV download, execution log tab
|
||||
- [x] 🟡 🎨 Row count badge, execution time badge
|
||||
- [x] 🟡 🎨 Save flow — name/description/meta on same form as builder; POST to store/update `saved_queries`
|
||||
- [x] 🟢 🎨 Empty state — centered “No rows returned” message in preview *(illustration asset optional)*
|
||||
- [ ] 🟢 🧪 Test: 0 rows, 1000+ rows truncated, columns with special characters
|
||||
|
||||
### 5.7 Query Cache
|
||||
|
||||
- [x] 🟡 ⚙️ After execution, store result JSON in `query_cache` with MD5 cache key and TTL
|
||||
- [x] 🟡 ⚙️ On next execution, check `query_cache` first; serve from cache if not expired
|
||||
- [x] 🟢 ⚙️ CI4 Cron task: `DeleteExpiredQueryCache` — run every 30 minutes to purge expired rows
|
||||
- [ ] 🟢 🧪 Test: cache hit serves faster, cache miss goes to DB, expired cache re-fetches
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 6 — Chart Builder
|
||||
### Estimated Time: 6–7 Days
|
||||
**Completion note:** Core MVP is in place: `ChartModel`, `SavedQueryRunner`, `ChartRenderer`, `ChartController`, multi-step builder (saved query first — no separate data-source step), ApexCharts live preview (`preview-query` / `preview-render`), `POST /chart/{id}/data` with CSRF refresh in JSON, chart list with Edit / Duplicate / Delete. Open: `public_token` generation, saved-query modal vs dropdown (dropdown implemented), unsaved-changes guard, table pagination/sort, legend position + custom hex + X-axis date format in UI, client `setInterval` refresh + spinner, search/filter, listed tests.
|
||||
|
||||
---
|
||||
|
||||
### 6.1 Chart Model
|
||||
|
||||
- [x] 🔴 ⚙️ `ChartModel.php` — CRUD, filter by workspace, soft delete
|
||||
- [ ] 🟡 ⚙️ Public token generator (`is_public` / `public_token`) — pending (sharing phase)
|
||||
- [ ] 🟢 🧪 Test: create chart with JSON display_config, retrieve and parse correctly
|
||||
|
||||
### 6.2 Chart Builder UI — Step Flow
|
||||
|
||||
- [x] 🔴 🎨 Multi-step chart builder UI (Step 1: Saved query → Type → Fields → Style → Preview & save)
|
||||
- [x] 🔴 🎨 Step 1 — Choose saved query + run preview *(data source implied by query; no separate source step)*
|
||||
- [x] 🔴 🎨 Step 2 — Chart type selector grid (12 types with icons)
|
||||
- [x] 🟡 🎨 Step 3 — Field mapping: X-axis, Y-axis, Group By, Value (+ combo second metric); columns from preview
|
||||
- [x] 🟡 🎨 Step 4 — Display settings: title, subtitle, palette presets, legend toggle, data label toggle, Y-axis min/max, number format, grid / smooth / stacked / horizontal bar
|
||||
- [x] 🟡 🎨 Step navigation — Back / Next / Save; validation before advancing
|
||||
- [x] 🟡 🎨 Live preview — ApexCharts with real query data (`chart/preview-render`)
|
||||
- [ ] 🟢 🎨 "Use Saved Query" button — modal to pick query *(dropdown on Step 1 covers pick-from-list)*
|
||||
- [ ] 🟢 🎨 Unsaved changes warning on browser back/close
|
||||
|
||||
### 6.3 Chart Rendering Engine
|
||||
|
||||
- [x] 🔴 ⚙️ `Libraries/ChartRenderer.php` — chart config + rows → Apex-compatible options (`buildPayload`)
|
||||
- [x] 🔴 ⚙️ Renderer: `bar`, `line`, `area`, `pie`, `donut`
|
||||
- [x] 🟡 ⚙️ Renderer: `scatter`, `kpi_card`, `funnel`, `gauge`, `heatmap`, `combo`
|
||||
- [ ] 🟡 ⚙️ `table` — paginated HTML table with sort *(basic truncated table only)*
|
||||
- [x] 🟢 ⚙️ Number formatter — currency (₹/$), percentage, decimals, K/M/B (server + client)
|
||||
- [ ] 🟢 🧪 Test: each chart type with realistic data, empty data, single-row data
|
||||
|
||||
### 6.4 Chart Display Settings
|
||||
|
||||
- [x] 🟡 🎨 Color palette presets (Ocean, Forest, Sunset, Mono) *(custom hex input — open)*
|
||||
- [ ] 🟡 🎨 Legend position selector (top/bottom/left/right/none) in builder UI
|
||||
- [x] 🟡 🎨 Refresh interval dropdown (Manual / 1 min / 5 min / 15 min / 1 hr) *(1 day option — open)*
|
||||
- [ ] 🟢 🎨 Date format selector for time-series X-axis (dd/MM, MMM dd, MMM yyyy)
|
||||
- [x] 🟢 🎨 Stacked bar/area toggle
|
||||
|
||||
### 6.5 Chart Auto-Refresh
|
||||
|
||||
- [ ] 🟡 🎨 JavaScript: `refresh_interval > 0` → `setInterval` re-fetch
|
||||
- [x] 🟡 ⚙️ `POST /chart/{id}/data` — re-run query, JSON render payload *(POST + `variables_json` for CSRF/vars; spec listed GET)*
|
||||
- [ ] 🟢 🎨 Refresh spinner overlay while loading
|
||||
- [ ] 🟢 🧪 Test: 1-minute refresh updates data without full page reload
|
||||
|
||||
### 6.6 Chart List & Management
|
||||
|
||||
- [x] 🟡 🎨 `app/Views/chart/index.php` — card grid, type, last updated, data source + query name
|
||||
- [x] 🟡 🎨 Chart card actions — Edit, Duplicate, Delete *(Add to Dashboard / Share — Phase 7+)*
|
||||
- [x] 🟡 ⚙️ `ChartController::duplicate()` — clone with name suffix `(copy)`
|
||||
- [ ] 🟢 🎨 Search and filter charts by type or data source
|
||||
- [ ] 🟢 🧪 Test: duplicate preserves all config, delete removes from all dashboards
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 7 — Dashboard Builder
|
||||
### Estimated Time: 6–7 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 7.1 Dashboard Model
|
||||
|
||||
- [ ] 🔴 ⚙️ `DashboardModel.php` — CRUD, filter by workspace, soft delete, public token generator
|
||||
- [ ] 🔴 ⚙️ `DashboardWidgetModel.php` — CRUD widgets per dashboard, store grid position
|
||||
- [ ] 🟢 🧪 Test: save layout_config JSON and retrieve widget positions correctly
|
||||
|
||||
### 7.2 Dashboard List
|
||||
|
||||
- [ ] 🔴 🎨 `app/Views/dashboard/index.php` — card grid of dashboards with pinned section at top
|
||||
- [ ] 🟡 🎨 Dashboard card — name, description, chart count, last updated, share status badge
|
||||
- [ ] 🟡 🎨 Create Dashboard button — modal with name + description input
|
||||
- [ ] 🟢 🎨 Pin/Unpin dashboard toggle
|
||||
- [ ] 🟢 🧪 Test: 0 dashboards empty state, pinned order preserved on reload
|
||||
|
||||
### 7.3 Dashboard View Mode
|
||||
|
||||
- [ ] 🔴 🎨 `app/Views/dashboard/view.php` — render all widgets in their grid positions
|
||||
- [ ] 🔴 🎨 Render chart widgets: fetch data via AJAX, render ApexCharts
|
||||
- [ ] 🟡 🎨 Render text widgets — parse Markdown to HTML using `marked.js`
|
||||
- [ ] 🟡 🎨 Render image widgets — `<img>` with configurable object-fit
|
||||
- [ ] 🟡 🎨 Global filter widgets — date range picker and dropdown filter
|
||||
- [ ] 🟡 ⚙️ When global date filter changes, re-fetch all chart data with new date params injected into queries
|
||||
- [ ] 🟢 🎨 Fullscreen button — expand dashboard to fill viewport, hide sidebar/topbar
|
||||
- [ ] 🟢 🧪 Test: mixed widget types, dashboard with 20+ charts, date filter propagation
|
||||
|
||||
### 7.4 Dashboard Edit Mode (Drag & Drop)
|
||||
|
||||
- [ ] 🔴 🎨 Integrate `gridstack.js` or `Muuri` for drag-and-drop grid layout
|
||||
- [ ] 🔴 🎨 "Edit Layout" toggle activates draggable/resizable mode on all widgets
|
||||
- [ ] 🟡 🎨 Resize handles on widget cards — drag corner to resize (min 1×1, max 4×3 units)
|
||||
- [ ] 🟡 🎨 "Add Widget" button in edit mode — opens modal to pick chart, text, image, or filter widget
|
||||
- [ ] 🟡 🎨 Remove widget button (✕) visible only in edit mode
|
||||
- [ ] 🟡 ⚙️ "Save Layout" — AJAX POST grid positions (x, y, w, h) per widget to `dashboard_widgets`
|
||||
- [ ] 🟡 🎨 "Discard Changes" — reload original layout from DB without saving
|
||||
- [ ] 🟢 🎨 Widget title override input (optional per-widget title different from chart name)
|
||||
- [ ] 🟢 🧪 Test: save layout, reload — positions preserved exactly; concurrent edit race condition
|
||||
|
||||
### 7.5 Dashboard Settings
|
||||
|
||||
- [ ] 🟡 🎨 Dashboard settings panel — name, description, theme (light/dark/system), refresh interval
|
||||
- [ ] 🟡 ⚙️ `DashboardController::updateSettings()` — update name, theme, refresh
|
||||
- [ ] 🟢 🎨 Danger zone — delete dashboard with confirmation typing
|
||||
- [ ] 🟢 🧪 Test: theme toggle persists on reload, refresh interval auto-starts
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 8 — Alerts & Notifications
|
||||
### Estimated Time: 4–5 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 8.1 Alert Model & CRUD
|
||||
|
||||
- [ ] 🔴 ⚙️ `AlertModel.php` — CRUD, filter by workspace, soft delete
|
||||
- [ ] 🔴 ⚙️ `AlertHistoryModel.php` — insert triggered log, fetch recent history per alert
|
||||
- [ ] 🟡 🎨 `app/Views/alert/index.php` — list alerts with status (OK/Triggered/Muted), last triggered time
|
||||
- [ ] 🟡 🎨 `app/Views/alert/create.php` — form: chart selector, metric field, condition, threshold, channels
|
||||
- [ ] 🟡 🎨 `app/Views/alert/edit.php` — edit + mute/unmute toggle
|
||||
- [ ] 🟢 🧪 Test: create alert with all fields, edit threshold, delete alert
|
||||
|
||||
### 8.2 Alert Engine (Background Check)
|
||||
|
||||
- [ ] 🔴 ⚙️ `Libraries/AlertEngine.php` — load active alerts, run chart query, compare value to threshold
|
||||
- [ ] 🔴 ⚙️ CI4 Cron (`php spark alert:check`) — runs every minute via system cron job
|
||||
- [ ] 🟡 ⚙️ `AlertEngine::evaluate()` — conditions: `gt`, `lt`, `eq`, `gte`, `lte`
|
||||
- [ ] 🟡 ⚙️ Skip alert if `is_muted_until` is in the future
|
||||
- [ ] 🟡 ⚙️ On trigger: send Email via CI4 Email library, send Slack message via Webhook HTTP POST
|
||||
- [ ] 🟡 ⚙️ Log result to `alert_history` — value, channels notified, status (sent/failed/muted)
|
||||
- [ ] 🟢 ⚙️ Prevent duplicate notifications — if same alert triggered within last 5 minutes, skip
|
||||
- [ ] 🟢 🧪 Test: threshold breach triggers notification, muted alert is skipped, failed webhook logs error
|
||||
|
||||
### 8.3 Alert Notifications UI
|
||||
|
||||
- [ ] 🟡 🎨 Alerts sidebar panel (as in POC) — show recent triggered alerts with current value
|
||||
- [ ] 🟡 🎨 Alert history sub-page — paginated log of all past triggers per alert
|
||||
- [ ] 🟡 🎨 "Mute for" button — snooze alert for 1h / 4h / 24h
|
||||
- [ ] 🟢 🎨 Red badge count on sidebar Alerts nav item for active triggered alerts
|
||||
- [ ] 🟢 🧪 Test: snooze clears badge, alert re-triggers after snooze expires
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 9 — Sharing & Embedding
|
||||
### Estimated Time: 3–4 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 9.1 Public Share Links
|
||||
|
||||
- [ ] 🔴 ⚙️ `SharedLinkModel.php` — create token, find by token, increment view count
|
||||
- [ ] 🔴 ⚙️ `SharingController::generate()` — create record in `shared_links`, return public URL
|
||||
- [ ] 🔴 ⚙️ Public route `GET /share/{token}` — no auth required, load dashboard/chart view-only
|
||||
- [ ] 🟡 🎨 Share modal in dashboard/chart view — show public URL with copy button, QR code
|
||||
- [ ] 🟡 🎨 Optional password field — bcrypt hash stored, prompt on public page if set
|
||||
- [ ] 🟡 🎨 Optional expiry date picker — after expiry show "This link has expired" page
|
||||
- [ ] 🟡 ⚙️ `SharingController::revoke()` — set `is_active = 0`, invalidate link
|
||||
- [ ] 🟢 🎨 View count display in share modal ("Viewed 42 times")
|
||||
- [ ] 🟢 🧪 Test: password protection, expired link, revoked link, view count increment
|
||||
|
||||
### 9.2 iFrame Embed
|
||||
|
||||
- [ ] 🟡 🎨 Embed tab in share modal — show iframe HTML snippet with correct URL
|
||||
- [ ] 🟡 ⚙️ Public share route: set `X-Frame-Options: ALLOWALL` header for embed URLs
|
||||
- [ ] 🟡 🎨 Embed view — stripped layout (no sidebar/topbar), chart/dashboard only
|
||||
- [ ] 🟢 🎨 Embed size presets (640×480, 800×600, 1200×800, custom)
|
||||
- [ ] 🟢 🧪 Test: embed renders in external `<iframe>`, responsive sizing
|
||||
|
||||
### 9.3 Export
|
||||
|
||||
- [ ] 🟡 🎨 Export chart as PNG — use ApexCharts `chart.dataURI()` + FileSaver.js client-side
|
||||
- [ ] 🟡 🎨 Export chart data as CSV — AJAX to `GET /chart/{id}/export?format=csv`
|
||||
- [ ] 🟡 ⚙️ `ChartController::export()` — re-run query, format as CSV using PHP `fputcsv`, stream download
|
||||
- [ ] 🟡 ⚙️ Export chart data as Excel — use `PhpSpreadsheet` library to generate `.xlsx`
|
||||
- [ ] 🟢 ⚙️ Log each export to `chart_exports` table
|
||||
- [ ] 🟢 🧪 Test: CSV column headers match query columns, Excel opens without errors
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 10 — REST API
|
||||
### Estimated Time: 3–4 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 10.1 API Authentication
|
||||
|
||||
- [ ] 🔴 ⚙️ `ApiAuthFilter.php` — extract Bearer token from `Authorization` header, find user in `users.api_token`
|
||||
- [ ] 🔴 ⚙️ Return `401 Unauthorized` JSON if token missing or invalid
|
||||
- [ ] 🟢 ⚙️ Rate limiter — 60 requests per minute per token using CI4 Throttler
|
||||
- [ ] 🟢 🧪 Test: valid token, invalid token, missing token, rate limit exceeded returns 429
|
||||
|
||||
### 10.2 API Endpoints
|
||||
|
||||
- [ ] 🔴 ⚙️ `Api/WorkspaceController` — `GET /api/v1/workspaces`, `POST /api/v1/workspaces`
|
||||
- [ ] 🔴 ⚙️ `Api/DataSourceController` — `GET /api/v1/workspaces/{id}/datasources`, `POST`, `DELETE`
|
||||
- [ ] 🔴 ⚙️ `Api/ChartController` — `GET /api/v1/workspaces/{id}/charts`, `POST`, `GET /api/v1/charts/{id}/data`
|
||||
- [ ] 🟡 ⚙️ `Api/DashboardController` — `GET`, `POST`, `PUT /api/v1/dashboards/{id}`
|
||||
- [ ] 🟡 ⚙️ `Api/QueryController` — `POST /api/v1/datasources/{id}/query` — run ad-hoc query
|
||||
- [ ] 🟡 ⚙️ `Api/AlertController` — `GET /api/v1/alerts`, `POST`, `DELETE`
|
||||
- [ ] 🟢 ⚙️ Consistent JSON response format: `{ success, data, message, errors }`
|
||||
- [ ] 🟢 📄 Generate Postman collection JSON for all API endpoints
|
||||
- [ ] 🟢 🧪 Test: each endpoint with valid/invalid workspace membership, missing fields
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 11 — Audit Logs
|
||||
### Estimated Time: 2–3 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
- [ ] 🔴 ⚙️ `Libraries/AuditLogger.php` — static `log(action, resource_type, resource_id, old, new)` method
|
||||
- [ ] 🔴 ⚙️ Hook `AuditLogger::log()` into key controller actions: create/update/delete chart, dashboard, data source; login/logout; role change; share link created/revoked
|
||||
- [ ] 🟡 🎨 `app/Views/audit/index.php` — paginated table of audit logs
|
||||
- [ ] 🟡 🎨 Filters — by user, by action type, by date range
|
||||
- [ ] 🟡 🎨 Log detail modal — show `old_value` and `new_value` JSON diff view
|
||||
- [ ] 🟢 ⚙️ Auto-capture `ip_address` and `user_agent` from CI4 `IncomingRequest`
|
||||
- [ ] 🟢 ⚙️ CI4 Cron: `PurgeOldAuditLogs` — delete logs older than 90 days (configurable)
|
||||
- [ ] 🟢 🧪 Test: login creates log entry, chart delete logs old_value, IP captured correctly
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 12 — Settings & Configuration
|
||||
### Estimated Time: 2 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
- [ ] 🟡 ⚙️ `SettingsModel.php` — get/set by key and workspace_id (null = global)
|
||||
- [ ] 🟡 🎨 `app/Views/settings/workspace.php` — name, logo, timezone, default refresh, default theme
|
||||
- [ ] 🟡 🎨 `app/Views/settings/notifications.php` — SMTP test button, Slack webhook test button
|
||||
- [ ] 🟡 🎨 `app/Views/admin/settings.php` (super admin) — allow_registration toggle, max workspaces
|
||||
- [ ] 🟢 🎨 Theme toggle (light/dark) saved to `users` table or localStorage with CI4 session sync
|
||||
- [ ] 🟢 🧪 Test: workspace settings persist after logout, global settings affect new registrations
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 13 — Polish, Testing & Security
|
||||
### Estimated Time: 4–5 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 13.1 UI/UX Polish
|
||||
|
||||
- [ ] 🟡 🎨 Loading states — skeleton shimmer on all chart cards while data loads
|
||||
- [ ] 🟡 🎨 Empty states — friendly illustrations for no charts, no data sources, no dashboards
|
||||
- [ ] 🟡 🎨 Toast notification system — success, error, warning toasts globally
|
||||
- [ ] 🟡 🎨 Breadcrumb navigation on all pages
|
||||
- [ ] 🟡 🎨 Responsive layout — sidebar collapses to hamburger on mobile
|
||||
- [ ] 🟢 🎨 Keyboard shortcuts — `N` = new chart, `D` = go to dashboards, `ESC` = close modal
|
||||
- [ ] 🟢 🎨 Dark mode — full dark theme toggle applied to all views
|
||||
- [ ] 🟢 🎨 404 and 500 custom error pages
|
||||
|
||||
### 13.2 Security Hardening
|
||||
|
||||
- [ ] 🔴 ⚙️ Enable CI4 CSRF protection on all POST forms (`Config/Security.php`)
|
||||
- [ ] 🔴 ⚙️ Enable CI4 XSS clean on all user inputs via `IncomingRequest::getVar()`
|
||||
- [ ] 🔴 ⚙️ Validate all user-supplied SQL through allowlist check — block `DROP`, `ALTER`, `GRANT`, etc.
|
||||
- [ ] 🟡 ⚙️ Set `HttpOnly`, `Secure`, `SameSite=Strict` on session cookie (`Config/Cookie.php`)
|
||||
- [ ] 🟡 ⚙️ Add Content Security Policy headers via `ResponseTrait` or middleware
|
||||
- [ ] 🟡 ⚙️ Add rate limiting on login route — block after 5 failed attempts for 15 minutes
|
||||
- [ ] 🟢 ⚙️ Sanitize all file uploads — allow only `jpg`, `png`, `csv`; validate MIME type server-side
|
||||
- [ ] 🟢 🧪 Test: CSRF token rejection, XSS payload in chart name, SQL injection in query field
|
||||
|
||||
### 13.3 Testing
|
||||
|
||||
- [ ] 🟡 🧪 PHPUnit: write feature tests for Auth module (register, login, logout, reset)
|
||||
- [ ] 🟡 🧪 PHPUnit: write feature tests for Chart CRUD
|
||||
- [ ] 🟡 🧪 PHPUnit: write feature tests for Dashboard CRUD and widget save
|
||||
- [ ] 🟡 🧪 PHPUnit: write unit tests for `ChartRenderer`, `QueryBuilder::toSQL()`, `AlertEngine::evaluate()`
|
||||
- [ ] 🟢 🧪 Browser test (manual): end-to-end user flow — register → create workspace → add data source → build chart → add to dashboard → share
|
||||
- [ ] 🟢 🧪 Load test: dashboard with 10 charts, each auto-refreshing every 5 minutes — check DB load
|
||||
|
||||
---
|
||||
|
||||
## ──────────────────────────────────────────
|
||||
## PHASE 14 — Deployment & Documentation
|
||||
### Estimated Time: 2–3 Days
|
||||
## ──────────────────────────────────────────
|
||||
|
||||
### 14.1 Deployment
|
||||
|
||||
- [ ] 🔴 ⚙️ Set `CI_ENVIRONMENT = production` in `.env`
|
||||
- [ ] 🔴 ⚙️ Configure web server (Apache `.htaccess` or Nginx `server {}`) to point root to `public/`
|
||||
- [ ] 🟡 ⚙️ Set up system cron jobs:
|
||||
- `* * * * * php /var/www/chart-board/spark alert:check`
|
||||
- `*/30 * * * * php /var/www/chart-board/spark cache:purge`
|
||||
- `0 2 * * * php /var/www/chart-board/spark logs:purge`
|
||||
- [ ] 🟡 ⚙️ Configure Redis for production cache (update `Config/Cache.php`)
|
||||
- [ ] 🟢 ⚙️ Set up log rotation for `writable/logs/`
|
||||
- [ ] 🟢 ⚙️ Production MySQL — create read-only user for data source connections
|
||||
- [ ] 🟢 ⚙️ Set up SSL certificate (Let's Encrypt)
|
||||
- [ ] 🟢 📄 Create `DEPLOYMENT.md` — server setup steps, Nginx config, cron setup
|
||||
|
||||
### 14.2 Documentation
|
||||
|
||||
- [ ] 🟡 📄 Complete `README.md` — finalize installation steps, config reference
|
||||
- [ ] 🟡 📄 `CONTRIBUTING.md` — code style (PSR-12), branch naming, PR checklist
|
||||
- [ ] 🟢 📄 Postman Collection JSON for full REST API
|
||||
- [ ] 🟢 📄 In-app help tooltips on query builder and chart builder fields
|
||||
- [ ] 🟢 📄 Changelog `CHANGELOG.md` — v1.0.0 feature list
|
||||
|
||||
---
|
||||
|
||||
## 📊 Summary
|
||||
|
||||
| Phase | Name | Est. Days | Priority |
|
||||
|-------|------|-----------|----------|
|
||||
| 1 | Foundation & Setup | 3–4 | 🔴 |
|
||||
| 2 | Auth & User Management | 4–5 | 🔴 |
|
||||
| 3 | Workspace Management | 3–4 | 🔴 |
|
||||
| 4 | Data Source Connections | 5–6 | 🔴 |
|
||||
| 5 | Query Builder | 5–6 | 🔴 |
|
||||
| 6 | Chart Builder | 6–7 | 🔴 |
|
||||
| 7 | Dashboard Builder | 6–7 | 🔴 |
|
||||
| 8 | Alerts & Notifications | 4–5 | 🟡 |
|
||||
| 9 | Sharing & Embedding | 3–4 | 🟡 |
|
||||
| 10 | REST API | 3–4 | 🟡 |
|
||||
| 11 | Audit Logs | 2–3 | 🟢 |
|
||||
| 12 | Settings | 2 | 🟢 |
|
||||
| 13 | Polish, Testing & Security | 4–5 | 🟡 |
|
||||
| 14 | Deployment & Docs | 2–3 | 🟡 |
|
||||
| **Total** | | **~56–68 days** | |
|
||||
|
||||
> **Note:** Phases 1–7 are the critical path (core MVP). Phases 8–14 are enhancement and hardening layers. A team of 2 developers can parallelize frontend and backend tasks within each phase to cut delivery time roughly in half.
|
||||
|
||||
---
|
||||
|
||||
*Chart-Board · Task Breakdown v1.0 · Generated for CI4 Build*
|
||||
6
app/.htaccess
Normal file
6
app/.htaccess
Normal file
@ -0,0 +1,6 @@
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
21
app/Commands/AlertCheck.php
Normal file
21
app/Commands/AlertCheck.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\AlertEngine;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class AlertCheck extends BaseCommand
|
||||
{
|
||||
protected $group = 'ChartBoard';
|
||||
protected $name = 'alert:check';
|
||||
protected $description = 'Evaluate active alerts and send notifications (run every minute via cron).';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$engine = new AlertEngine();
|
||||
$n = $engine->runAll();
|
||||
CLI::write('Alerts evaluated: ' . $n, 'green');
|
||||
}
|
||||
}
|
||||
23
app/Commands/DeleteExpiredQueryCache.php
Normal file
23
app/Commands/DeleteExpiredQueryCache.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class DeleteExpiredQueryCache extends BaseCommand
|
||||
{
|
||||
protected $group = 'ChartBoard';
|
||||
protected $name = 'chartboard:query-cache:purge';
|
||||
protected $description = 'Delete expired query cache entries.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$builder = db_connect()->table('query_cache');
|
||||
$builder->where('expires_at <', date('Y-m-d H:i:s'));
|
||||
$builder->delete();
|
||||
$count = db_connect()->affectedRows();
|
||||
|
||||
CLI::write('Expired query cache rows deleted: ' . $count, 'green');
|
||||
}
|
||||
}
|
||||
32
app/Commands/PurgeOldAuditLogs.php
Normal file
32
app/Commands/PurgeOldAuditLogs.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Audit;
|
||||
|
||||
class PurgeOldAuditLogs extends BaseCommand
|
||||
{
|
||||
protected $group = 'ChartBoard';
|
||||
protected $name = 'chartboard:audit:purge';
|
||||
protected $description = 'Delete audit log rows older than the configured retention period.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$days = (new Audit())->retentionDays;
|
||||
if ($days < 1) {
|
||||
CLI::write('Retention days must be at least 1.', 'yellow');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = date('Y-m-d H:i:s', strtotime('-' . $days . ' days'));
|
||||
$builder = db_connect()->table('audit_logs');
|
||||
$builder->where('created_at <', $cutoff);
|
||||
$builder->delete();
|
||||
$count = db_connect()->affectedRows();
|
||||
|
||||
CLI::write("Deleted audit logs older than {$days} days (before {$cutoff}): {$count}", 'green');
|
||||
}
|
||||
}
|
||||
15
app/Common.php
Normal file
15
app/Common.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The goal of this file is to allow developers a location
|
||||
* where they can overwrite core procedural functions and
|
||||
* replace them with their own. This file is loaded during
|
||||
* the bootstrap process and is called during the framework's
|
||||
* execution.
|
||||
*
|
||||
* This can be looked at as a `master helper` file that is
|
||||
* loaded early on, and may also contain additional functions
|
||||
* that you'd like to use throughout your entire application
|
||||
*
|
||||
* @see: https://codeigniter.com/user_guide/extending/common.html
|
||||
*/
|
||||
202
app/Config/App.php
Normal file
202
app/Config/App.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class App extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Base Site URL
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* URL to your CodeIgniter root. Typically, this will be your base URL,
|
||||
* WITH a trailing slash:
|
||||
*
|
||||
* E.g., http://example.com/
|
||||
*/
|
||||
public string $baseURL = 'http://localhost/ChartBoard/public/';
|
||||
|
||||
/**
|
||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||
* If you want to accept multiple Hostnames, set this.
|
||||
*
|
||||
* E.g.,
|
||||
* When your site URL ($baseURL) is 'http://example.com/', and your site
|
||||
* also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
|
||||
* ['media.example.com', 'accounts.example.com']
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $allowedHostnames = [];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Index File
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Typically, this will be your `index.php` file, unless you've renamed it to
|
||||
* something else. If you have configured your web server to remove this file
|
||||
* from your site URIs, set this variable to an empty string.
|
||||
*/
|
||||
public string $indexPage = '';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* URI PROTOCOL
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This item determines which server global should be used to retrieve the
|
||||
* URI string. The default setting of 'REQUEST_URI' works for most servers.
|
||||
* If your links do not seem to work, try one of the other delicious flavors:
|
||||
*
|
||||
* 'REQUEST_URI': Uses $_SERVER['REQUEST_URI']
|
||||
* 'QUERY_STRING': Uses $_SERVER['QUERY_STRING']
|
||||
* 'PATH_INFO': Uses $_SERVER['PATH_INFO']
|
||||
*
|
||||
* WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded!
|
||||
*/
|
||||
public string $uriProtocol = 'REQUEST_URI';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed URL Characters
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This lets you specify which characters are permitted within your URLs.
|
||||
| When someone tries to submit a URL with disallowed characters they will
|
||||
| get a warning message.
|
||||
|
|
||||
| As a security measure you are STRONGLY encouraged to restrict URLs to
|
||||
| as few characters as possible.
|
||||
|
|
||||
| By default, only these are allowed: `a-z 0-9~%.:_-`
|
||||
|
|
||||
| Set an empty string to allow all characters -- but only if you are insane.
|
||||
|
|
||||
| The configured value is actually a regular expression character group
|
||||
| and it will be used as: '/\A[<permittedURIChars>]+\z/iu'
|
||||
|
|
||||
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
||||
|
|
||||
*/
|
||||
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Default Locale
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The Locale roughly represents the language and location that your visitor
|
||||
* is viewing the site from. It affects the language strings and other
|
||||
* strings (like currency markers, numbers, etc), that your program
|
||||
* should run under for this request.
|
||||
*/
|
||||
public string $defaultLocale = 'en';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Negotiate Locale
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If true, the current Request object will automatically determine the
|
||||
* language to use based on the value of the Accept-Language header.
|
||||
*
|
||||
* If false, no automatic detection will be performed.
|
||||
*/
|
||||
public bool $negotiateLocale = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Supported Locales
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If $negotiateLocale is true, this array lists the locales supported
|
||||
* by the application in descending order of priority. If no match is
|
||||
* found, the first locale will be used.
|
||||
*
|
||||
* IncomingRequest::setLocale() also uses this list.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportedLocales = ['en'];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Application Timezone
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The default timezone that will be used in your application to display
|
||||
* dates with the date helper, and can be retrieved through app_timezone()
|
||||
*
|
||||
* @see https://www.php.net/manual/en/timezones.php for list of timezones
|
||||
* supported by PHP.
|
||||
*/
|
||||
public string $appTimezone = 'Asia/Kolkata';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Default Character Set
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This determines which character set is used by default in various methods
|
||||
* that require a character set to be provided.
|
||||
*
|
||||
* @see http://php.net/htmlspecialchars for a list of supported charsets.
|
||||
*/
|
||||
public string $charset = 'UTF-8';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Force Global Secure Requests
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If true, this will force every request made to this application to be
|
||||
* made via a secure connection (HTTPS). If the incoming request is not
|
||||
* secure, the user will be redirected to a secure version of the page
|
||||
* and the HTTP Strict Transport Security (HSTS) header will be set.
|
||||
*/
|
||||
public bool $forceGlobalSecureRequests = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Reverse Proxy IPs
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If your server is behind a reverse proxy, you must whitelist the proxy
|
||||
* IP addresses from which CodeIgniter should trust headers such as
|
||||
* X-Forwarded-For or Client-IP in order to properly identify
|
||||
* the visitor's IP address.
|
||||
*
|
||||
* You need to set a proxy IP address or IP address with subnets and
|
||||
* the HTTP header for the client IP address.
|
||||
*
|
||||
* Here are some examples:
|
||||
* [
|
||||
* '10.0.1.200' => 'X-Forwarded-For',
|
||||
* '192.168.5.0/24' => 'X-Real-IP',
|
||||
* ]
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $proxyIPs = [];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Content Security Policy
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Enables the Response's Content Secure Policy to restrict the sources that
|
||||
* can be used for images, scripts, CSS files, audio, video, etc. If enabled,
|
||||
* the Response object will populate default values for the policy from the
|
||||
* `ContentSecurityPolicy.php` file. Controllers can always add to those
|
||||
* restrictions at run time.
|
||||
*
|
||||
* For a better understanding of CSP, see these documents:
|
||||
*
|
||||
* @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/
|
||||
* @see http://www.w3.org/TR/CSP/
|
||||
*/
|
||||
public bool $CSPEnabled = false;
|
||||
}
|
||||
13
app/Config/Audit.php
Normal file
13
app/Config/Audit.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Audit extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Delete audit rows older than this many days (cron: chartboard:audit:purge).
|
||||
*/
|
||||
public int $retentionDays = 90;
|
||||
}
|
||||
92
app/Config/Autoload.php
Normal file
92
app/Config/Autoload.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\AutoloadConfig;
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* AUTOLOADER CONFIGURATION
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* This file defines the namespaces and class maps so the Autoloader
|
||||
* can find the files as needed.
|
||||
*
|
||||
* NOTE: If you use an identical key in $psr4 or $classmap, then
|
||||
* the values in this file will overwrite the framework's values.
|
||||
*
|
||||
* NOTE: This class is required prior to Autoloader instantiation,
|
||||
* and does not extend BaseConfig.
|
||||
*/
|
||||
class Autoload extends AutoloadConfig
|
||||
{
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Namespaces
|
||||
* -------------------------------------------------------------------
|
||||
* This maps the locations of any namespaces in your application to
|
||||
* their location on the file system. These are used by the autoloader
|
||||
* to locate files the first time they have been instantiated.
|
||||
*
|
||||
* The 'Config' (APPPATH . 'Config') and 'CodeIgniter' (SYSTEMPATH) are
|
||||
* already mapped for you.
|
||||
*
|
||||
* You may change the name of the 'App' namespace if you wish,
|
||||
* but this should be done prior to creating any namespaced classes,
|
||||
* else you will need to modify all of those classes for this to work.
|
||||
*
|
||||
* @var array<string, list<string>|string>
|
||||
*/
|
||||
public $psr4 = [
|
||||
APP_NAMESPACE => APPPATH,
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Class Map
|
||||
* -------------------------------------------------------------------
|
||||
* The class map provides a map of class names and their exact
|
||||
* location on the drive. Classes loaded in this manner will have
|
||||
* slightly faster performance because they will not have to be
|
||||
* searched for within one or more directories as they would if they
|
||||
* were being autoloaded through a namespace.
|
||||
*
|
||||
* Prototype:
|
||||
* $classmap = [
|
||||
* 'MyClass' => '/path/to/class/file.php'
|
||||
* ];
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public $classmap = [];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Files
|
||||
* -------------------------------------------------------------------
|
||||
* The files array provides a list of paths to __non-class__ files
|
||||
* that will be autoloaded. This can be useful for bootstrap operations
|
||||
* or for loading functions.
|
||||
*
|
||||
* Prototype:
|
||||
* $files = [
|
||||
* '/path/to/my/file.php',
|
||||
* ];
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $files = [];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Helpers
|
||||
* -------------------------------------------------------------------
|
||||
* Prototype:
|
||||
* $helpers = [
|
||||
* 'form',
|
||||
* ];
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $helpers = [];
|
||||
}
|
||||
34
app/Config/Boot/development.php
Normal file
34
app/Config/Boot/development.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ERROR DISPLAY
|
||||
|--------------------------------------------------------------------------
|
||||
| In development, we want to show as many errors as possible to help
|
||||
| make sure they don't make it to production. And save us hours of
|
||||
| painful debugging.
|
||||
|
|
||||
| If you set 'display_errors' to '1', CI4's detailed error report will show.
|
||||
*/
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEBUG BACKTRACES
|
||||
|--------------------------------------------------------------------------
|
||||
| If true, this constant will tell the error screens to display debug
|
||||
| backtraces along with the other error information. If you would
|
||||
| prefer to not see this, set this value to false.
|
||||
*/
|
||||
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEBUG MODE
|
||||
|--------------------------------------------------------------------------
|
||||
| Debug mode is an experimental flag that can allow changes throughout
|
||||
| the system. This will control whether Kint is loaded, and a few other
|
||||
| items. It can always be used within your own application too.
|
||||
*/
|
||||
defined('CI_DEBUG') || define('CI_DEBUG', true);
|
||||
25
app/Config/Boot/production.php
Normal file
25
app/Config/Boot/production.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ERROR DISPLAY
|
||||
|--------------------------------------------------------------------------
|
||||
| Don't show ANY in production environments. Instead, let the system catch
|
||||
| it and display a generic error message.
|
||||
|
|
||||
| If you set 'display_errors' to '1', CI4's detailed error report will show.
|
||||
*/
|
||||
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||
// If you want to suppress more types of errors.
|
||||
// error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED & ~E_STRICT & ~E_USER_NOTICE & ~E_USER_DEPRECATED);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEBUG MODE
|
||||
|--------------------------------------------------------------------------
|
||||
| Debug mode is an experimental flag that can allow changes throughout
|
||||
| the system. It's not widely used currently, and may not survive
|
||||
| release of the framework.
|
||||
*/
|
||||
defined('CI_DEBUG') || define('CI_DEBUG', false);
|
||||
38
app/Config/Boot/testing.php
Normal file
38
app/Config/Boot/testing.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The environment testing is reserved for PHPUnit testing. It has special
|
||||
* conditions built into the framework at various places to assist with that.
|
||||
* You can’t use it for your development.
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ERROR DISPLAY
|
||||
|--------------------------------------------------------------------------
|
||||
| In development, we want to show as many errors as possible to help
|
||||
| make sure they don't make it to production. And save us hours of
|
||||
| painful debugging.
|
||||
*/
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEBUG BACKTRACES
|
||||
|--------------------------------------------------------------------------
|
||||
| If true, this constant will tell the error screens to display debug
|
||||
| backtraces along with the other error information. If you would
|
||||
| prefer to not see this, set this value to false.
|
||||
*/
|
||||
defined('SHOW_DEBUG_BACKTRACE') || define('SHOW_DEBUG_BACKTRACE', true);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DEBUG MODE
|
||||
|--------------------------------------------------------------------------
|
||||
| Debug mode is an experimental flag that can allow changes throughout
|
||||
| the system. It's not widely used currently, and may not survive
|
||||
| release of the framework.
|
||||
*/
|
||||
defined('CI_DEBUG') || define('CI_DEBUG', true);
|
||||
20
app/Config/CURLRequest.php
Normal file
20
app/Config/CURLRequest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class CURLRequest extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CURLRequest Share Options
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Whether share options between requests or not.
|
||||
*
|
||||
* If true, all the options won't be reset between requests.
|
||||
* It may cause an error request with unnecessary headers.
|
||||
*/
|
||||
public bool $shareOptions = false;
|
||||
}
|
||||
162
app/Config/Cache.php
Normal file
162
app/Config/Cache.php
Normal file
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Cache\CacheInterface;
|
||||
use CodeIgniter\Cache\Handlers\DummyHandler;
|
||||
use CodeIgniter\Cache\Handlers\FileHandler;
|
||||
use CodeIgniter\Cache\Handlers\MemcachedHandler;
|
||||
use CodeIgniter\Cache\Handlers\PredisHandler;
|
||||
use CodeIgniter\Cache\Handlers\RedisHandler;
|
||||
use CodeIgniter\Cache\Handlers\WincacheHandler;
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Cache extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Primary Handler
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The name of the preferred handler that should be used. If for some reason
|
||||
* it is not available, the $backupHandler will be used in its place.
|
||||
*/
|
||||
public string $handler = 'file';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Backup Handler
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The name of the handler that will be used in case the first one is
|
||||
* unreachable. Often, 'file' is used here since the filesystem is
|
||||
* always available, though that's not always practical for the app.
|
||||
*/
|
||||
public string $backupHandler = 'file';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Key Prefix
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This string is added to all cache item names to help avoid collisions
|
||||
* if you run multiple applications with the same cache engine.
|
||||
*/
|
||||
public string $prefix = '';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Default TTL
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The default number of seconds to save items when none is specified.
|
||||
*
|
||||
* WARNING: This is not used by framework handlers where 60 seconds is
|
||||
* hard-coded, but may be useful to projects and modules. This will replace
|
||||
* the hard-coded value in a future release.
|
||||
*/
|
||||
public int $ttl = 60;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Reserved Characters
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* A string of reserved characters that will not be allowed in keys or tags.
|
||||
* Strings that violate this restriction will cause handlers to throw.
|
||||
* Default: {}()/\@:
|
||||
*
|
||||
* NOTE: The default set is required for PSR-6 compliance.
|
||||
*/
|
||||
public string $reservedCharacters = '{}()/\@:';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* File settings
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Your file storage preferences can be specified below, if you are using
|
||||
* the File driver.
|
||||
*
|
||||
* @var array{storePath?: string, mode?: int}
|
||||
*/
|
||||
public array $file = [
|
||||
'storePath' => WRITEPATH . 'cache/',
|
||||
'mode' => 0640,
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------------
|
||||
* Memcached settings
|
||||
* -------------------------------------------------------------------------
|
||||
*
|
||||
* Your Memcached servers can be specified below, if you are using
|
||||
* the Memcached drivers.
|
||||
*
|
||||
* @see https://codeigniter.com/user_guide/libraries/caching.html#memcached
|
||||
*
|
||||
* @var array{host?: string, port?: int, weight?: int, raw?: bool}
|
||||
*/
|
||||
public array $memcached = [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 11211,
|
||||
'weight' => 1,
|
||||
'raw' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------------
|
||||
* Redis settings
|
||||
* -------------------------------------------------------------------------
|
||||
*
|
||||
* Your Redis server can be specified below, if you are using
|
||||
* the Redis or Predis drivers.
|
||||
*
|
||||
* @var array{host?: string, password?: string|null, port?: int, timeout?: int, database?: int}
|
||||
*/
|
||||
public array $redis = [
|
||||
'host' => '127.0.0.1',
|
||||
'password' => '',
|
||||
'port' => 6379,
|
||||
'timeout' => 0,
|
||||
'database' => 0,
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Available Cache Handlers
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is an array of cache engine alias' and class names. Only engines
|
||||
* that are listed here are allowed to be used.
|
||||
*
|
||||
* @var array<string, class-string<CacheInterface>>
|
||||
*/
|
||||
public array $validHandlers = [
|
||||
'dummy' => DummyHandler::class,
|
||||
'file' => FileHandler::class,
|
||||
'memcached' => MemcachedHandler::class,
|
||||
'predis' => PredisHandler::class,
|
||||
'redis' => RedisHandler::class,
|
||||
'wincache' => WincacheHandler::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Web Page Caching: Cache Include Query String
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Whether to take the URL query string into consideration when generating
|
||||
* output cache files. Valid options are:
|
||||
*
|
||||
* false = Disabled
|
||||
* true = Enabled, take all query parameters into account.
|
||||
* Please be aware that this may result in numerous cache
|
||||
* files generated for the same page over and over again.
|
||||
* ['q'] = Enabled, but only take into account the specified list
|
||||
* of query parameters.
|
||||
*
|
||||
* @var bool|list<string>
|
||||
*/
|
||||
public $cacheQueryString = false;
|
||||
}
|
||||
79
app/Config/Constants.php
Normal file
79
app/Config/Constants.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
| --------------------------------------------------------------------
|
||||
| App Namespace
|
||||
| --------------------------------------------------------------------
|
||||
|
|
||||
| This defines the default Namespace that is used throughout
|
||||
| CodeIgniter to refer to the Application directory. Change
|
||||
| this constant to change the namespace that all application
|
||||
| classes should use.
|
||||
|
|
||||
| NOTE: changing this will require manually modifying the
|
||||
| existing namespaces of App\* namespaced-classes.
|
||||
*/
|
||||
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
|
||||
|
||||
/*
|
||||
| --------------------------------------------------------------------------
|
||||
| Composer Path
|
||||
| --------------------------------------------------------------------------
|
||||
|
|
||||
| The path that Composer's autoload file is expected to live. By default,
|
||||
| the vendor folder is in the Root directory, but you can customize that here.
|
||||
*/
|
||||
defined('COMPOSER_PATH') || define('COMPOSER_PATH', ROOTPATH . 'vendor/autoload.php');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Timing Constants
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Provide simple ways to work with the myriad of PHP functions that
|
||||
| require information to be in seconds.
|
||||
*/
|
||||
defined('SECOND') || define('SECOND', 1);
|
||||
defined('MINUTE') || define('MINUTE', 60);
|
||||
defined('HOUR') || define('HOUR', 3600);
|
||||
defined('DAY') || define('DAY', 86400);
|
||||
defined('WEEK') || define('WEEK', 604800);
|
||||
defined('MONTH') || define('MONTH', 2_592_000);
|
||||
defined('YEAR') || define('YEAR', 31_536_000);
|
||||
defined('DECADE') || define('DECADE', 315_360_000);
|
||||
|
||||
/*
|
||||
| --------------------------------------------------------------------------
|
||||
| Exit Status Codes
|
||||
| --------------------------------------------------------------------------
|
||||
|
|
||||
| Used to indicate the conditions under which the script is exit()ing.
|
||||
| While there is no universal standard for error codes, there are some
|
||||
| broad conventions. Three such conventions are mentioned below, for
|
||||
| those who wish to make use of them. The CodeIgniter defaults were
|
||||
| chosen for the least overlap with these conventions, while still
|
||||
| leaving room for others to be defined in future versions and user
|
||||
| applications.
|
||||
|
|
||||
| The three main conventions used for determining exit status codes
|
||||
| are as follows:
|
||||
|
|
||||
| Standard C/C++ Library (stdlibc):
|
||||
| http://www.gnu.org/software/libc/manual/html_node/Exit-Status.html
|
||||
| (This link also contains other GNU-specific conventions)
|
||||
| BSD sysexits.h:
|
||||
| http://www.gsp.com/cgi-bin/man.cgi?section=3&topic=sysexits
|
||||
| Bash scripting:
|
||||
| http://tldp.org/LDP/abs/html/exitcodes.html
|
||||
|
|
||||
*/
|
||||
defined('EXIT_SUCCESS') || define('EXIT_SUCCESS', 0); // no errors
|
||||
defined('EXIT_ERROR') || define('EXIT_ERROR', 1); // generic error
|
||||
defined('EXIT_CONFIG') || define('EXIT_CONFIG', 3); // configuration error
|
||||
defined('EXIT_UNKNOWN_FILE') || define('EXIT_UNKNOWN_FILE', 4); // file not found
|
||||
defined('EXIT_UNKNOWN_CLASS') || define('EXIT_UNKNOWN_CLASS', 5); // unknown class
|
||||
defined('EXIT_UNKNOWN_METHOD') || define('EXIT_UNKNOWN_METHOD', 6); // unknown class member
|
||||
defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7); // invalid user input
|
||||
defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error
|
||||
defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code
|
||||
defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code
|
||||
176
app/Config/ContentSecurityPolicy.php
Normal file
176
app/Config/ContentSecurityPolicy.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Stores the default settings for the ContentSecurityPolicy, if you
|
||||
* choose to use it. The values here will be read in and set as defaults
|
||||
* for the site. If needed, they can be overridden on a page-by-page basis.
|
||||
*
|
||||
* Suggested reference for explanations:
|
||||
*
|
||||
* @see https://www.html5rocks.com/en/tutorials/security/content-security-policy/
|
||||
*/
|
||||
class ContentSecurityPolicy extends BaseConfig
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Broadbrush CSP management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default CSP report context
|
||||
*/
|
||||
public bool $reportOnly = false;
|
||||
|
||||
/**
|
||||
* Specifies a URL where a browser will send reports
|
||||
* when a content security policy is violated.
|
||||
*/
|
||||
public ?string $reportURI = null;
|
||||
|
||||
/**
|
||||
* Instructs user agents to rewrite URL schemes, changing
|
||||
* HTTP to HTTPS. This directive is for websites with
|
||||
* large numbers of old URLs that need to be rewritten.
|
||||
*/
|
||||
public bool $upgradeInsecureRequests = false;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sources allowed
|
||||
// NOTE: once you set a policy to 'none', it cannot be further restricted
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Will default to self if not overridden
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $defaultSrc;
|
||||
|
||||
/**
|
||||
* Lists allowed scripts' URLs.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $scriptSrc = 'self';
|
||||
|
||||
/**
|
||||
* Lists allowed stylesheets' URLs.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $styleSrc = 'self';
|
||||
|
||||
/**
|
||||
* Defines the origins from which images can be loaded.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $imageSrc = 'self';
|
||||
|
||||
/**
|
||||
* Restricts the URLs that can appear in a page's `<base>` element.
|
||||
*
|
||||
* Will default to self if not overridden
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $baseURI;
|
||||
|
||||
/**
|
||||
* Lists the URLs for workers and embedded frame contents
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $childSrc = 'self';
|
||||
|
||||
/**
|
||||
* Limits the origins that you can connect to (via XHR,
|
||||
* WebSockets, and EventSource).
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $connectSrc = 'self';
|
||||
|
||||
/**
|
||||
* Specifies the origins that can serve web fonts.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $fontSrc;
|
||||
|
||||
/**
|
||||
* Lists valid endpoints for submission from `<form>` tags.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $formAction = 'self';
|
||||
|
||||
/**
|
||||
* Specifies the sources that can embed the current page.
|
||||
* This directive applies to `<frame>`, `<iframe>`, `<embed>`,
|
||||
* and `<applet>` tags. This directive can't be used in
|
||||
* `<meta>` tags and applies only to non-HTML resources.
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $frameAncestors;
|
||||
|
||||
/**
|
||||
* The frame-src directive restricts the URLs which may
|
||||
* be loaded into nested browsing contexts.
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $frameSrc;
|
||||
|
||||
/**
|
||||
* Restricts the origins allowed to deliver video and audio.
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $mediaSrc;
|
||||
|
||||
/**
|
||||
* Allows control over Flash and other plugins.
|
||||
*
|
||||
* @var list<string>|string
|
||||
*/
|
||||
public $objectSrc = 'self';
|
||||
|
||||
/**
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $manifestSrc;
|
||||
|
||||
/**
|
||||
* Limits the kinds of plugins a page may invoke.
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $pluginTypes;
|
||||
|
||||
/**
|
||||
* List of actions allowed.
|
||||
*
|
||||
* @var list<string>|string|null
|
||||
*/
|
||||
public $sandbox;
|
||||
|
||||
/**
|
||||
* Nonce tag for style
|
||||
*/
|
||||
public string $styleNonceTag = '{csp-style-nonce}';
|
||||
|
||||
/**
|
||||
* Nonce tag for script
|
||||
*/
|
||||
public string $scriptNonceTag = '{csp-script-nonce}';
|
||||
|
||||
/**
|
||||
* Replace nonce tag automatically
|
||||
*/
|
||||
public bool $autoNonce = true;
|
||||
}
|
||||
107
app/Config/Cookie.php
Normal file
107
app/Config/Cookie.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use DateTimeInterface;
|
||||
|
||||
class Cookie extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Prefix
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Set a cookie name prefix if you need to avoid collisions.
|
||||
*/
|
||||
public string $prefix = '';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Expires Timestamp
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Default expires timestamp for cookies. Setting this to `0` will mean the
|
||||
* cookie will not have the `Expires` attribute and will behave as a session
|
||||
* cookie.
|
||||
*
|
||||
* @var DateTimeInterface|int|string
|
||||
*/
|
||||
public $expires = 0;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Path
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Typically will be a forward slash.
|
||||
*/
|
||||
public string $path = '/';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Domain
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Set to `.your-domain.com` for site-wide cookies.
|
||||
*/
|
||||
public string $domain = '';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Secure
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Cookie will only be set if a secure HTTPS connection exists.
|
||||
*/
|
||||
public bool $secure = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie HTTPOnly
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Cookie will only be accessible via HTTP(S) (no JavaScript).
|
||||
*/
|
||||
public bool $httponly = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie SameSite
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Configure cookie SameSite setting. Allowed values are:
|
||||
* - None
|
||||
* - Lax
|
||||
* - Strict
|
||||
* - ''
|
||||
*
|
||||
* Alternatively, you can use the constant names:
|
||||
* - `Cookie::SAMESITE_NONE`
|
||||
* - `Cookie::SAMESITE_LAX`
|
||||
* - `Cookie::SAMESITE_STRICT`
|
||||
*
|
||||
* Defaults to `Lax` for compatibility with modern browsers. Setting `''`
|
||||
* (empty string) means default SameSite attribute set by browsers (`Lax`)
|
||||
* will be set on cookies. If set to `None`, `$secure` must also be set.
|
||||
*
|
||||
* @var ''|'Lax'|'None'|'Strict'
|
||||
*/
|
||||
public string $samesite = 'Lax';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Cookie Raw
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This flag allows setting a "raw" cookie, i.e., its name and value are
|
||||
* not URL encoded using `rawurlencode()`.
|
||||
*
|
||||
* If this is set to `true`, cookie names should be compliant of RFC 2616's
|
||||
* list of allowed characters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
|
||||
* @see https://tools.ietf.org/html/rfc2616#section-2.2
|
||||
*/
|
||||
public bool $raw = false;
|
||||
}
|
||||
105
app/Config/Cors.php
Normal file
105
app/Config/Cors.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Cross-Origin Resource Sharing (CORS) Configuration
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
*/
|
||||
class Cors extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* The default CORS configuration.
|
||||
*
|
||||
* @var array{
|
||||
* allowedOrigins: list<string>,
|
||||
* allowedOriginsPatterns: list<string>,
|
||||
* supportsCredentials: bool,
|
||||
* allowedHeaders: list<string>,
|
||||
* exposedHeaders: list<string>,
|
||||
* allowedMethods: list<string>,
|
||||
* maxAge: int,
|
||||
* }
|
||||
*/
|
||||
public array $default = [
|
||||
/**
|
||||
* Origins for the `Access-Control-Allow-Origin` header.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
||||
*
|
||||
* E.g.:
|
||||
* - ['http://localhost:8080']
|
||||
* - ['https://www.example.com']
|
||||
*/
|
||||
'allowedOrigins' => [],
|
||||
|
||||
/**
|
||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
||||
*
|
||||
* NOTE: A pattern specified here is part of a regular expression. It will
|
||||
* be actually `#\A<pattern>\z#`.
|
||||
*
|
||||
* E.g.:
|
||||
* - ['https://\w+\.example\.com']
|
||||
*/
|
||||
'allowedOriginsPatterns' => [],
|
||||
|
||||
/**
|
||||
* Weather to send the `Access-Control-Allow-Credentials` header.
|
||||
*
|
||||
* The Access-Control-Allow-Credentials response header tells browsers whether
|
||||
* the server allows cross-origin HTTP requests to include credentials.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
||||
*/
|
||||
'supportsCredentials' => false,
|
||||
|
||||
/**
|
||||
* Set headers to allow.
|
||||
*
|
||||
* The Access-Control-Allow-Headers response header is used in response to
|
||||
* a preflight request which includes the Access-Control-Request-Headers to
|
||||
* indicate which HTTP headers can be used during the actual request.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
*/
|
||||
'allowedHeaders' => [],
|
||||
|
||||
/**
|
||||
* Set headers to expose.
|
||||
*
|
||||
* The Access-Control-Expose-Headers response header allows a server to
|
||||
* indicate which response headers should be made available to scripts running
|
||||
* in the browser, in response to a cross-origin request.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
|
||||
*/
|
||||
'exposedHeaders' => [],
|
||||
|
||||
/**
|
||||
* Set methods to allow.
|
||||
*
|
||||
* The Access-Control-Allow-Methods response header specifies one or more
|
||||
* methods allowed when accessing a resource in response to a preflight
|
||||
* request.
|
||||
*
|
||||
* E.g.:
|
||||
* - ['GET', 'POST', 'PUT', 'DELETE']
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
*/
|
||||
'allowedMethods' => [],
|
||||
|
||||
/**
|
||||
* Set how many seconds the results of a preflight request can be cached.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
|
||||
*/
|
||||
'maxAge' => 7200,
|
||||
];
|
||||
}
|
||||
204
app/Config/Database.php
Normal file
204
app/Config/Database.php
Normal file
@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Database\Config;
|
||||
|
||||
/**
|
||||
* Database Configuration
|
||||
*/
|
||||
class Database extends Config
|
||||
{
|
||||
/**
|
||||
* The directory that holds the Migrations and Seeds directories.
|
||||
*/
|
||||
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
|
||||
|
||||
/**
|
||||
* Lets you choose which connection group to use if no other is specified.
|
||||
*/
|
||||
public string $defaultGroup = 'default';
|
||||
|
||||
/**
|
||||
* The default database connection.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $default = [
|
||||
'DSN' => '',
|
||||
'hostname' => 'localhost',
|
||||
'username' => 'root',
|
||||
'password' => '',
|
||||
'database' => 'chartboard',
|
||||
'DBDriver' => 'MySQLi',
|
||||
'DBPrefix' => '',
|
||||
'pConnect' => false,
|
||||
'DBDebug' => true,
|
||||
'charset' => 'utf8mb4',
|
||||
'DBCollat' => 'utf8mb4_general_ci',
|
||||
'swapPre' => '',
|
||||
'encrypt' => false,
|
||||
'compress' => false,
|
||||
'strictOn' => false,
|
||||
'failover' => [],
|
||||
'port' => 3306,
|
||||
'numberNative' => false,
|
||||
'foundRows' => false,
|
||||
'dateFormat' => [
|
||||
'date' => 'Y-m-d',
|
||||
'datetime' => 'Y-m-d H:i:s',
|
||||
'time' => 'H:i:s',
|
||||
],
|
||||
];
|
||||
|
||||
// /**
|
||||
// * Sample database connection for SQLite3.
|
||||
// *
|
||||
// * @var array<string, mixed>
|
||||
// */
|
||||
// public array $default = [
|
||||
// 'database' => 'database.db',
|
||||
// 'DBDriver' => 'SQLite3',
|
||||
// 'DBPrefix' => '',
|
||||
// 'DBDebug' => true,
|
||||
// 'swapPre' => '',
|
||||
// 'failover' => [],
|
||||
// 'foreignKeys' => true,
|
||||
// 'busyTimeout' => 1000,
|
||||
// 'synchronous' => null,
|
||||
// 'dateFormat' => [
|
||||
// 'date' => 'Y-m-d',
|
||||
// 'datetime' => 'Y-m-d H:i:s',
|
||||
// 'time' => 'H:i:s',
|
||||
// ],
|
||||
// ];
|
||||
|
||||
// /**
|
||||
// * Sample database connection for Postgre.
|
||||
// *
|
||||
// * @var array<string, mixed>
|
||||
// */
|
||||
// public array $default = [
|
||||
// 'DSN' => '',
|
||||
// 'hostname' => 'localhost',
|
||||
// 'username' => 'root',
|
||||
// 'password' => 'root',
|
||||
// 'database' => 'ci4',
|
||||
// 'schema' => 'public',
|
||||
// 'DBDriver' => 'Postgre',
|
||||
// 'DBPrefix' => '',
|
||||
// 'pConnect' => false,
|
||||
// 'DBDebug' => true,
|
||||
// 'charset' => 'utf8',
|
||||
// 'swapPre' => '',
|
||||
// 'failover' => [],
|
||||
// 'port' => 5432,
|
||||
// 'dateFormat' => [
|
||||
// 'date' => 'Y-m-d',
|
||||
// 'datetime' => 'Y-m-d H:i:s',
|
||||
// 'time' => 'H:i:s',
|
||||
// ],
|
||||
// ];
|
||||
|
||||
// /**
|
||||
// * Sample database connection for SQLSRV.
|
||||
// *
|
||||
// * @var array<string, mixed>
|
||||
// */
|
||||
// public array $default = [
|
||||
// 'DSN' => '',
|
||||
// 'hostname' => 'localhost',
|
||||
// 'username' => 'root',
|
||||
// 'password' => 'root',
|
||||
// 'database' => 'ci4',
|
||||
// 'schema' => 'dbo',
|
||||
// 'DBDriver' => 'SQLSRV',
|
||||
// 'DBPrefix' => '',
|
||||
// 'pConnect' => false,
|
||||
// 'DBDebug' => true,
|
||||
// 'charset' => 'utf8',
|
||||
// 'swapPre' => '',
|
||||
// 'encrypt' => false,
|
||||
// 'failover' => [],
|
||||
// 'port' => 1433,
|
||||
// 'dateFormat' => [
|
||||
// 'date' => 'Y-m-d',
|
||||
// 'datetime' => 'Y-m-d H:i:s',
|
||||
// 'time' => 'H:i:s',
|
||||
// ],
|
||||
// ];
|
||||
|
||||
// /**
|
||||
// * Sample database connection for OCI8.
|
||||
// *
|
||||
// * You may need the following environment variables:
|
||||
// * NLS_LANG = 'AMERICAN_AMERICA.UTF8'
|
||||
// * NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
|
||||
// * NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
|
||||
// * NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
|
||||
// *
|
||||
// * @var array<string, mixed>
|
||||
// */
|
||||
// public array $default = [
|
||||
// 'DSN' => 'localhost:1521/XEPDB1',
|
||||
// 'username' => 'root',
|
||||
// 'password' => 'root',
|
||||
// 'DBDriver' => 'OCI8',
|
||||
// 'DBPrefix' => '',
|
||||
// 'pConnect' => false,
|
||||
// 'DBDebug' => true,
|
||||
// 'charset' => 'AL32UTF8',
|
||||
// 'swapPre' => '',
|
||||
// 'failover' => [],
|
||||
// 'dateFormat' => [
|
||||
// 'date' => 'Y-m-d',
|
||||
// 'datetime' => 'Y-m-d H:i:s',
|
||||
// 'time' => 'H:i:s',
|
||||
// ],
|
||||
// ];
|
||||
|
||||
/**
|
||||
* This database connection is used when running PHPUnit database tests.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $tests = [
|
||||
'DSN' => '',
|
||||
'hostname' => '127.0.0.1',
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'database' => ':memory:',
|
||||
'DBDriver' => 'SQLite3',
|
||||
'DBPrefix' => 'db_', // Needed to ensure we're working correctly with prefixes live. DO NOT REMOVE FOR CI DEVS
|
||||
'pConnect' => false,
|
||||
'DBDebug' => true,
|
||||
'charset' => 'utf8',
|
||||
'DBCollat' => '',
|
||||
'swapPre' => '',
|
||||
'encrypt' => false,
|
||||
'compress' => false,
|
||||
'strictOn' => false,
|
||||
'failover' => [],
|
||||
'port' => 3306,
|
||||
'foreignKeys' => true,
|
||||
'busyTimeout' => 1000,
|
||||
'synchronous' => null,
|
||||
'dateFormat' => [
|
||||
'date' => 'Y-m-d',
|
||||
'datetime' => 'Y-m-d H:i:s',
|
||||
'time' => 'H:i:s',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
// Ensure that we always set the database group to 'tests' if
|
||||
// we are currently running an automated test suite, so that
|
||||
// we don't overwrite live data on accident.
|
||||
if (ENVIRONMENT === 'testing') {
|
||||
$this->defaultGroup = 'tests';
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Config/DocTypes.php
Normal file
43
app/Config/DocTypes.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
class DocTypes
|
||||
{
|
||||
/**
|
||||
* List of valid document types.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $list = [
|
||||
'xhtml11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
|
||||
'xhtml1-strict' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
|
||||
'xhtml1-trans' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
|
||||
'xhtml1-frame' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">',
|
||||
'xhtml-basic11' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">',
|
||||
'html5' => '<!DOCTYPE html>',
|
||||
'html4-strict' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
|
||||
'html4-trans' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">',
|
||||
'html4-frame' => '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">',
|
||||
'mathml1' => '<!DOCTYPE math SYSTEM "http://www.w3.org/Math/DTD/mathml1/mathml.dtd">',
|
||||
'mathml2' => '<!DOCTYPE math PUBLIC "-//W3C//DTD MathML 2.0//EN" "http://www.w3.org/Math/DTD/mathml2/mathml2.dtd">',
|
||||
'svg10' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">',
|
||||
'svg11' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
|
||||
'svg11-basic' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd">',
|
||||
'svg11-tiny' => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">',
|
||||
'xhtml-math-svg-xh' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
|
||||
'xhtml-math-svg-sh' => '<!DOCTYPE svg:svg PUBLIC "-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN" "http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd">',
|
||||
'xhtml-rdfa-1' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.0//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-1.dtd">',
|
||||
'xhtml-rdfa-2' => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML+RDFa 1.1//EN" "http://www.w3.org/MarkUp/DTD/xhtml-rdfa-2.dtd">',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether to remove the solidus (`/`) character for void HTML elements (e.g. `<input>`)
|
||||
* for HTML5 compatibility.
|
||||
*
|
||||
* Set to:
|
||||
* `true` - to be HTML5 compatible
|
||||
* `false` - to be XHTML compatible
|
||||
*/
|
||||
public bool $html5 = true;
|
||||
}
|
||||
121
app/Config/Email.php
Normal file
121
app/Config/Email.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Email extends BaseConfig
|
||||
{
|
||||
public string $fromEmail = 'noreply@chartboard.local';
|
||||
public string $fromName = 'Chart-Board';
|
||||
public string $recipients = '';
|
||||
|
||||
/**
|
||||
* The "user agent"
|
||||
*/
|
||||
public string $userAgent = 'CodeIgniter';
|
||||
|
||||
/**
|
||||
* The mail sending protocol: mail, sendmail, smtp
|
||||
*/
|
||||
public string $protocol = 'smtp';
|
||||
|
||||
/**
|
||||
* The server path to Sendmail.
|
||||
*/
|
||||
public string $mailPath = '/usr/sbin/sendmail';
|
||||
|
||||
/**
|
||||
* SMTP Server Hostname
|
||||
*/
|
||||
public string $SMTPHost = 'localhost';
|
||||
|
||||
/**
|
||||
* SMTP Username
|
||||
*/
|
||||
public string $SMTPUser = '';
|
||||
|
||||
/**
|
||||
* SMTP Password
|
||||
*/
|
||||
public string $SMTPPass = '';
|
||||
|
||||
/**
|
||||
* SMTP Port
|
||||
*/
|
||||
public int $SMTPPort = 1025;
|
||||
|
||||
/**
|
||||
* SMTP Timeout (in seconds)
|
||||
*/
|
||||
public int $SMTPTimeout = 5;
|
||||
|
||||
/**
|
||||
* Enable persistent SMTP connections
|
||||
*/
|
||||
public bool $SMTPKeepAlive = false;
|
||||
|
||||
/**
|
||||
* SMTP Encryption.
|
||||
*
|
||||
* @var string '', 'tls' or 'ssl'. 'tls' will issue a STARTTLS command
|
||||
* to the server. 'ssl' means implicit SSL. Connection on port
|
||||
* 465 should set this to ''.
|
||||
*/
|
||||
public string $SMTPCrypto = '';
|
||||
|
||||
/**
|
||||
* Enable word-wrap
|
||||
*/
|
||||
public bool $wordWrap = true;
|
||||
|
||||
/**
|
||||
* Character count to wrap at
|
||||
*/
|
||||
public int $wrapChars = 76;
|
||||
|
||||
/**
|
||||
* Type of mail, either 'text' or 'html'
|
||||
*/
|
||||
public string $mailType = 'html';
|
||||
|
||||
/**
|
||||
* Character set (utf-8, iso-8859-1, etc.)
|
||||
*/
|
||||
public string $charset = 'UTF-8';
|
||||
|
||||
/**
|
||||
* Whether to validate the email address
|
||||
*/
|
||||
public bool $validate = false;
|
||||
|
||||
/**
|
||||
* Email Priority. 1 = highest. 5 = lowest. 3 = normal
|
||||
*/
|
||||
public int $priority = 3;
|
||||
|
||||
/**
|
||||
* Newline character. (Use “\r\n” to comply with RFC 822)
|
||||
*/
|
||||
public string $CRLF = "\r\n";
|
||||
|
||||
/**
|
||||
* Newline character. (Use “\r\n” to comply with RFC 822)
|
||||
*/
|
||||
public string $newline = "\r\n";
|
||||
|
||||
/**
|
||||
* Enable BCC Batch Mode.
|
||||
*/
|
||||
public bool $BCCBatchMode = false;
|
||||
|
||||
/**
|
||||
* Number of emails in each BCC batch
|
||||
*/
|
||||
public int $BCCBatchSize = 200;
|
||||
|
||||
/**
|
||||
* Enable notify message from server
|
||||
*/
|
||||
public bool $DSN = false;
|
||||
}
|
||||
92
app/Config/Encryption.php
Normal file
92
app/Config/Encryption.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Encryption configuration.
|
||||
*
|
||||
* These are the settings used for encryption, if you don't pass a parameter
|
||||
* array to the encrypter for creation/initialization.
|
||||
*/
|
||||
class Encryption extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Encryption Key Starter
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If you use the Encryption class you must set an encryption key (seed).
|
||||
* You need to ensure it is long enough for the cipher and mode you plan to use.
|
||||
* See the user guide for more info.
|
||||
*/
|
||||
public string $key = '';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Encryption Driver to Use
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* One of the supported encryption drivers.
|
||||
*
|
||||
* Available drivers:
|
||||
* - OpenSSL
|
||||
* - Sodium
|
||||
*/
|
||||
public string $driver = 'OpenSSL';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* SodiumHandler's Padding Length in Bytes
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is the number of bytes that will be padded to the plaintext message
|
||||
* before it is encrypted. This value should be greater than zero.
|
||||
*
|
||||
* See the user guide for more information on padding.
|
||||
*/
|
||||
public int $blockSize = 16;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Encryption digest
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* HMAC digest to use, e.g. 'SHA512' or 'SHA256'. Default value is 'SHA512'.
|
||||
*/
|
||||
public string $digest = 'SHA512';
|
||||
|
||||
/**
|
||||
* Whether the cipher-text should be raw. If set to false, then it will be base64 encoded.
|
||||
* This setting is only used by OpenSSLHandler.
|
||||
*
|
||||
* Set to false for CI3 Encryption compatibility.
|
||||
*/
|
||||
public bool $rawData = true;
|
||||
|
||||
/**
|
||||
* Encryption key info.
|
||||
* This setting is only used by OpenSSLHandler.
|
||||
*
|
||||
* Set to 'encryption' for CI3 Encryption compatibility.
|
||||
*/
|
||||
public string $encryptKeyInfo = '';
|
||||
|
||||
/**
|
||||
* Authentication key info.
|
||||
* This setting is only used by OpenSSLHandler.
|
||||
*
|
||||
* Set to 'authentication' for CI3 Encryption compatibility.
|
||||
*/
|
||||
public string $authKeyInfo = '';
|
||||
|
||||
/**
|
||||
* Cipher to use.
|
||||
* This setting is only used by OpenSSLHandler.
|
||||
*
|
||||
* Set to 'AES-128-CBC' to decrypt encrypted data that encrypted
|
||||
* by CI3 Encryption default configuration.
|
||||
*/
|
||||
public string $cipher = 'AES-256-CTR';
|
||||
}
|
||||
55
app/Config/Events.php
Normal file
55
app/Config/Events.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Events\Events;
|
||||
use CodeIgniter\Exceptions\FrameworkException;
|
||||
use CodeIgniter\HotReloader\HotReloader;
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
* Application Events
|
||||
* --------------------------------------------------------------------
|
||||
* Events allow you to tap into the execution of the program without
|
||||
* modifying or extending core files. This file provides a central
|
||||
* location to define your events, though they can always be added
|
||||
* at run-time, also, if needed.
|
||||
*
|
||||
* You create code that can execute by subscribing to events with
|
||||
* the 'on()' method. This accepts any form of callable, including
|
||||
* Closures, that will be executed when the event is triggered.
|
||||
*
|
||||
* Example:
|
||||
* Events::on('create', [$myInstance, 'myMethod']);
|
||||
*/
|
||||
|
||||
Events::on('pre_system', static function (): void {
|
||||
if (ENVIRONMENT !== 'testing') {
|
||||
if (ini_get('zlib.output_compression')) {
|
||||
throw FrameworkException::forEnabledZlibOutputCompression();
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
ob_start(static fn ($buffer) => $buffer);
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
* Debug Toolbar Listeners.
|
||||
* --------------------------------------------------------------------
|
||||
* If you delete, they will no longer be collected.
|
||||
*/
|
||||
if (CI_DEBUG && ! is_cli()) {
|
||||
Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect');
|
||||
service('toolbar')->respond();
|
||||
// Hot Reload route - for framework use on the hot reloader.
|
||||
if (ENVIRONMENT === 'development') {
|
||||
service('routes')->get('__hot-reload', static function (): void {
|
||||
(new HotReloader())->run();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
106
app/Config/Exceptions.php
Normal file
106
app/Config/Exceptions.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Debug\ExceptionHandler;
|
||||
use CodeIgniter\Debug\ExceptionHandlerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Setup how the exception handler works.
|
||||
*/
|
||||
class Exceptions extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* LOG EXCEPTIONS?
|
||||
* --------------------------------------------------------------------------
|
||||
* If true, then exceptions will be logged
|
||||
* through Services::Log.
|
||||
*
|
||||
* Default: true
|
||||
*/
|
||||
public bool $log = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* DO NOT LOG STATUS CODES
|
||||
* --------------------------------------------------------------------------
|
||||
* Any status codes here will NOT be logged if logging is turned on.
|
||||
* By default, only 404 (Page Not Found) exceptions are ignored.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
public array $ignoreCodes = [404];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Error Views Path
|
||||
* --------------------------------------------------------------------------
|
||||
* This is the path to the directory that contains the 'cli' and 'html'
|
||||
* directories that hold the views used to generate errors.
|
||||
*
|
||||
* Default: APPPATH.'Views/errors'
|
||||
*/
|
||||
public string $errorViewPath = APPPATH . 'Views/errors';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* HIDE FROM DEBUG TRACE
|
||||
* --------------------------------------------------------------------------
|
||||
* Any data that you would like to hide from the debug trace.
|
||||
* In order to specify 2 levels, use "/" to separate.
|
||||
* ex. ['server', 'setup/password', 'secret_token']
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $sensitiveDataInTrace = [];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* WHETHER TO THROW AN EXCEPTION ON DEPRECATED ERRORS
|
||||
* --------------------------------------------------------------------------
|
||||
* If set to `true`, DEPRECATED errors are only logged and no exceptions are
|
||||
* thrown. This option also works for user deprecations.
|
||||
*/
|
||||
public bool $logDeprecations = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* LOG LEVEL THRESHOLD FOR DEPRECATIONS
|
||||
* --------------------------------------------------------------------------
|
||||
* If `$logDeprecations` is set to `true`, this sets the log level
|
||||
* to which the deprecation will be logged. This should be one of the log
|
||||
* levels recognized by PSR-3.
|
||||
*
|
||||
* The related `Config\Logger::$threshold` should be adjusted, if needed,
|
||||
* to capture logging the deprecations.
|
||||
*/
|
||||
public string $deprecationLogLevel = LogLevel::WARNING;
|
||||
|
||||
/*
|
||||
* DEFINE THE HANDLERS USED
|
||||
* --------------------------------------------------------------------------
|
||||
* Given the HTTP status code, returns exception handler that
|
||||
* should be used to deal with this error. By default, it will run CodeIgniter's
|
||||
* default handler and display the error information in the expected format
|
||||
* for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected
|
||||
* response format.
|
||||
*
|
||||
* Custom handlers can be returned if you want to handle one or more specific
|
||||
* error codes yourself like:
|
||||
*
|
||||
* if (in_array($statusCode, [400, 404, 500])) {
|
||||
* return new \App\Libraries\MyExceptionHandler();
|
||||
* }
|
||||
* if ($exception instanceOf PageNotFoundException) {
|
||||
* return new \App\Libraries\MyExceptionHandler();
|
||||
* }
|
||||
*/
|
||||
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
|
||||
{
|
||||
return new ExceptionHandler($this);
|
||||
}
|
||||
}
|
||||
37
app/Config/Feature.php
Normal file
37
app/Config/Feature.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Enable/disable backward compatibility breaking features.
|
||||
*/
|
||||
class Feature extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Use improved new auto routing instead of the legacy version.
|
||||
*/
|
||||
public bool $autoRoutesImproved = true;
|
||||
|
||||
/**
|
||||
* Use filter execution order in 4.4 or before.
|
||||
*/
|
||||
public bool $oldFilterOrder = false;
|
||||
|
||||
/**
|
||||
* The behavior of `limit(0)` in Query Builder.
|
||||
*
|
||||
* If true, `limit(0)` returns all records. (the behavior of 4.4.x or before in version 4.x.)
|
||||
* If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.)
|
||||
*/
|
||||
public bool $limitZeroAsAll = true;
|
||||
|
||||
/**
|
||||
* Use strict location negotiation.
|
||||
*
|
||||
* By default, the locale is selected based on a loose comparison of the language code (ISO 639-1)
|
||||
* Enabling strict comparison will also consider the region code (ISO 3166-1 alpha-2).
|
||||
*/
|
||||
public bool $strictLocaleNegotiation = false;
|
||||
}
|
||||
124
app/Config/Filters.php
Normal file
124
app/Config/Filters.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\Filters as BaseFilters;
|
||||
use CodeIgniter\Filters\Cors;
|
||||
use CodeIgniter\Filters\CSRF;
|
||||
use CodeIgniter\Filters\DebugToolbar;
|
||||
use CodeIgniter\Filters\ForceHTTPS;
|
||||
use CodeIgniter\Filters\Honeypot;
|
||||
use CodeIgniter\Filters\InvalidChars;
|
||||
use CodeIgniter\Filters\PageCache;
|
||||
use CodeIgniter\Filters\PerformanceMetrics;
|
||||
use CodeIgniter\Filters\SecureHeaders;
|
||||
use App\Filters\ApiAuthFilter;
|
||||
use App\Filters\ActiveWorkspaceFilter;
|
||||
use App\Filters\AuthFilter;
|
||||
use App\Filters\RoleFilter;
|
||||
use App\Filters\WorkspaceAdminFilter;
|
||||
|
||||
class Filters extends BaseFilters
|
||||
{
|
||||
/**
|
||||
* Configures aliases for Filter classes to
|
||||
* make reading things nicer and simpler.
|
||||
*
|
||||
* @var array<string, class-string|list<class-string>>
|
||||
*
|
||||
* [filter_name => classname]
|
||||
* or [filter_name => [classname1, classname2, ...]]
|
||||
*/
|
||||
public array $aliases = [
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
'honeypot' => Honeypot::class,
|
||||
'invalidchars' => InvalidChars::class,
|
||||
'secureheaders' => SecureHeaders::class,
|
||||
'cors' => Cors::class,
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'auth' => AuthFilter::class,
|
||||
'role' => RoleFilter::class,
|
||||
'apiauth' => ApiAuthFilter::class,
|
||||
'workspace' => ActiveWorkspaceFilter::class,
|
||||
'workspaceadmin' => WorkspaceAdminFilter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* List of special required filters.
|
||||
*
|
||||
* The filters listed here are special. They are applied before and after
|
||||
* other kinds of filters, and always applied even if a route does not exist.
|
||||
*
|
||||
* Filters set by default provide framework functionality. If removed,
|
||||
* those functions will no longer work.
|
||||
*
|
||||
* @see https://codeigniter.com/user_guide/incoming/filters.html#provided-filters
|
||||
*
|
||||
* @var array{before: list<string>, after: list<string>}
|
||||
*/
|
||||
public array $required = [
|
||||
'before' => [
|
||||
'forcehttps', // Force Global Secure Requests
|
||||
'pagecache', // Web Page Caching
|
||||
],
|
||||
'after' => [
|
||||
'pagecache', // Web Page Caching
|
||||
'performance', // Performance Metrics
|
||||
'toolbar', // Debug Toolbar
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* List of filter aliases that are always
|
||||
* applied before and after every request.
|
||||
*
|
||||
* @var array{
|
||||
* before: array<string, array{except: list<string>|string}>|list<string>,
|
||||
* after: array<string, array{except: list<string>|string}>|list<string>
|
||||
* }
|
||||
*/
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
// 'honeypot',
|
||||
// 'csrf',
|
||||
// 'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
// 'honeypot',
|
||||
// 'secureheaders',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* List of filter aliases that works on a
|
||||
* particular HTTP method (GET, POST, etc.).
|
||||
*
|
||||
* Example:
|
||||
* 'POST' => ['foo', 'bar']
|
||||
*
|
||||
* If you use this, you should disable auto-routing because auto-routing
|
||||
* permits any HTTP method to access a controller. Accessing the controller
|
||||
* with a method you don't expect could bypass the filter.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
public array $methods = [];
|
||||
// Example for strict method handling:
|
||||
// public array $methods = [
|
||||
// 'options' => ['cors'],
|
||||
// ];
|
||||
|
||||
/**
|
||||
* List of filter aliases that should run on any
|
||||
* before or after URI patterns.
|
||||
*
|
||||
* Example:
|
||||
* 'isLoggedIn' => ['before' => ['account/*', 'profiles/*']]
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
public array $filters = [];
|
||||
}
|
||||
12
app/Config/ForeignCharacters.php
Normal file
12
app/Config/ForeignCharacters.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\ForeignCharacters as BaseForeignCharacters;
|
||||
|
||||
/**
|
||||
* @immutable
|
||||
*/
|
||||
class ForeignCharacters extends BaseForeignCharacters
|
||||
{
|
||||
}
|
||||
64
app/Config/Format.php
Normal file
64
app/Config/Format.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Format\JSONFormatter;
|
||||
use CodeIgniter\Format\XMLFormatter;
|
||||
|
||||
class Format extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Available Response Formats
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* When you perform content negotiation with the request, these are the
|
||||
* available formats that your application supports. This is currently
|
||||
* only used with the API\ResponseTrait. A valid Formatter must exist
|
||||
* for the specified format.
|
||||
*
|
||||
* These formats are only checked when the data passed to the respond()
|
||||
* method is an array.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportedResponseFormats = [
|
||||
'application/json',
|
||||
'application/xml', // machine-readable XML
|
||||
'text/xml', // human-readable XML
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Formatters
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Lists the class to use to format responses with of a particular type.
|
||||
* For each mime type, list the class that should be used. Formatters
|
||||
* can be retrieved through the getFormatter() method.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $formatters = [
|
||||
'application/json' => JSONFormatter::class,
|
||||
'application/xml' => XMLFormatter::class,
|
||||
'text/xml' => XMLFormatter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Formatters Options
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Additional Options to adjust default formatters behaviour.
|
||||
* For each mime type, list the additional options that should be used.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
public array $formatterOptions = [
|
||||
'application/json' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||||
'application/xml' => 0,
|
||||
'text/xml' => 0,
|
||||
];
|
||||
}
|
||||
44
app/Config/Generators.php
Normal file
44
app/Config/Generators.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Generators extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Generator Commands' Views
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This array defines the mapping of generator commands to the view files
|
||||
* they are using. If you need to customize them for your own, copy these
|
||||
* view files in your own folder and indicate the location here.
|
||||
*
|
||||
* You will notice that the views have special placeholders enclosed in
|
||||
* curly braces `{...}`. These placeholders are used internally by the
|
||||
* generator commands in processing replacements, thus you are warned
|
||||
* not to delete them or modify the names. If you will do so, you may
|
||||
* end up disrupting the scaffolding process and throw errors.
|
||||
*
|
||||
* YOU HAVE BEEN WARNED!
|
||||
*
|
||||
* @var array<string, array<string, string>|string>
|
||||
*/
|
||||
public array $views = [
|
||||
'make:cell' => [
|
||||
'class' => 'CodeIgniter\Commands\Generators\Views\cell.tpl.php',
|
||||
'view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php',
|
||||
],
|
||||
'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php',
|
||||
'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php',
|
||||
'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php',
|
||||
'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php',
|
||||
'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php',
|
||||
'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
|
||||
'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php',
|
||||
'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php',
|
||||
'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php',
|
||||
'session:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php',
|
||||
];
|
||||
}
|
||||
42
app/Config/Honeypot.php
Normal file
42
app/Config/Honeypot.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Honeypot extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Makes Honeypot visible or not to human
|
||||
*/
|
||||
public bool $hidden = true;
|
||||
|
||||
/**
|
||||
* Honeypot Label Content
|
||||
*/
|
||||
public string $label = 'Fill This Field';
|
||||
|
||||
/**
|
||||
* Honeypot Field Name
|
||||
*/
|
||||
public string $name = 'honeypot';
|
||||
|
||||
/**
|
||||
* Honeypot HTML Template
|
||||
*/
|
||||
public string $template = '<label>{label}</label><input type="text" name="{name}" value="">';
|
||||
|
||||
/**
|
||||
* Honeypot container
|
||||
*
|
||||
* If you enabled CSP, you can remove `style="display:none"`.
|
||||
*/
|
||||
public string $container = '<div style="display:none">{template}</div>';
|
||||
|
||||
/**
|
||||
* The id attribute for Honeypot container tag
|
||||
*
|
||||
* Used when CSP is enabled.
|
||||
*/
|
||||
public string $containerId = 'hpc';
|
||||
}
|
||||
31
app/Config/Images.php
Normal file
31
app/Config/Images.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Images\Handlers\GDHandler;
|
||||
use CodeIgniter\Images\Handlers\ImageMagickHandler;
|
||||
|
||||
class Images extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Default handler used if no other handler is specified.
|
||||
*/
|
||||
public string $defaultHandler = 'gd';
|
||||
|
||||
/**
|
||||
* The path to the image library.
|
||||
* Required for ImageMagick, GraphicsMagick, or NetPBM.
|
||||
*/
|
||||
public string $libraryPath = '/usr/local/bin/convert';
|
||||
|
||||
/**
|
||||
* The available handler classes.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $handlers = [
|
||||
'gd' => GDHandler::class,
|
||||
'imagick' => ImageMagickHandler::class,
|
||||
];
|
||||
}
|
||||
63
app/Config/Kint.php
Normal file
63
app/Config/Kint.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use Kint\Parser\ConstructablePluginInterface;
|
||||
use Kint\Renderer\Rich\TabPluginInterface;
|
||||
use Kint\Renderer\Rich\ValuePluginInterface;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Kint
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* We use Kint's `RichRenderer` and `CLIRenderer`. This area contains options
|
||||
* that you can set to customize how Kint works for you.
|
||||
*
|
||||
* @see https://kint-php.github.io/kint/ for details on these settings.
|
||||
*/
|
||||
class Kint
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* @var list<class-string<ConstructablePluginInterface>|ConstructablePluginInterface>|null
|
||||
*/
|
||||
public $plugins;
|
||||
|
||||
public int $maxDepth = 6;
|
||||
public bool $displayCalledFrom = true;
|
||||
public bool $expanded = false;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RichRenderer Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public string $richTheme = 'aante-light.css';
|
||||
public bool $richFolder = false;
|
||||
|
||||
/**
|
||||
* @var array<string, class-string<ValuePluginInterface>>|null
|
||||
*/
|
||||
public $richObjectPlugins;
|
||||
|
||||
/**
|
||||
* @var array<string, class-string<TabPluginInterface>>|null
|
||||
*/
|
||||
public $richTabPlugins;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CLI Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
public bool $cliColors = true;
|
||||
public bool $cliForceUTF8 = false;
|
||||
public bool $cliDetectWidth = true;
|
||||
public int $cliMinWidth = 40;
|
||||
}
|
||||
151
app/Config/Logger.php
Normal file
151
app/Config/Logger.php
Normal file
@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Log\Handlers\FileHandler;
|
||||
use CodeIgniter\Log\Handlers\HandlerInterface;
|
||||
|
||||
class Logger extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Error Logging Threshold
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* You can enable error logging by setting a threshold over zero. The
|
||||
* threshold determines what gets logged. Any values below or equal to the
|
||||
* threshold will be logged.
|
||||
*
|
||||
* Threshold options are:
|
||||
*
|
||||
* - 0 = Disables logging, Error logging TURNED OFF
|
||||
* - 1 = Emergency Messages - System is unusable
|
||||
* - 2 = Alert Messages - Action Must Be Taken Immediately
|
||||
* - 3 = Critical Messages - Application component unavailable, unexpected exception.
|
||||
* - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
|
||||
* - 5 = Warnings - Exceptional occurrences that are not errors.
|
||||
* - 6 = Notices - Normal but significant events.
|
||||
* - 7 = Info - Interesting events, like user logging in, etc.
|
||||
* - 8 = Debug - Detailed debug information.
|
||||
* - 9 = All Messages
|
||||
*
|
||||
* You can also pass an array with threshold levels to show individual error types
|
||||
*
|
||||
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
|
||||
*
|
||||
* For a live site you'll usually enable Critical or higher (3) to be logged otherwise
|
||||
* your log files will fill up very fast.
|
||||
*
|
||||
* @var int|list<int>
|
||||
*/
|
||||
public $threshold = (ENVIRONMENT === 'production') ? 4 : 9;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Date Format for Logs
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Each item that is logged has an associated date. You can use PHP date
|
||||
* codes to set your own date formatting
|
||||
*/
|
||||
public string $dateFormat = 'Y-m-d H:i:s';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Log Handlers
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The logging system supports multiple actions to be taken when something
|
||||
* is logged. This is done by allowing for multiple Handlers, special classes
|
||||
* designed to write the log to their chosen destinations, whether that is
|
||||
* a file on the getServer, a cloud-based service, or even taking actions such
|
||||
* as emailing the dev team.
|
||||
*
|
||||
* Each handler is defined by the class name used for that handler, and it
|
||||
* MUST implement the `CodeIgniter\Log\Handlers\HandlerInterface` interface.
|
||||
*
|
||||
* The value of each key is an array of configuration items that are sent
|
||||
* to the constructor of each handler. The only required configuration item
|
||||
* is the 'handles' element, which must be an array of integer log levels.
|
||||
* This is most easily handled by using the constants defined in the
|
||||
* `Psr\Log\LogLevel` class.
|
||||
*
|
||||
* Handlers are executed in the order defined in this array, starting with
|
||||
* the handler on top and continuing down.
|
||||
*
|
||||
* @var array<class-string<HandlerInterface>, array<string, int|list<string>|string>>
|
||||
*/
|
||||
public array $handlers = [
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
* File Handler
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
FileHandler::class => [
|
||||
// The log levels that this handler will handle.
|
||||
'handles' => [
|
||||
'critical',
|
||||
'alert',
|
||||
'emergency',
|
||||
'debug',
|
||||
'error',
|
||||
'info',
|
||||
'notice',
|
||||
'warning',
|
||||
],
|
||||
|
||||
/*
|
||||
* The default filename extension for log files.
|
||||
* An extension of 'php' allows for protecting the log files via basic
|
||||
* scripting, when they are to be stored under a publicly accessible directory.
|
||||
*
|
||||
* NOTE: Leaving it blank will default to 'log'.
|
||||
*/
|
||||
'fileExtension' => '',
|
||||
|
||||
/*
|
||||
* The file system permissions to be applied on newly created log files.
|
||||
*
|
||||
* IMPORTANT: This MUST be an integer (no quotes) and you MUST use octal
|
||||
* integer notation (i.e. 0700, 0644, etc.)
|
||||
*/
|
||||
'filePermissions' => 0644,
|
||||
|
||||
/*
|
||||
* Logging Directory Path
|
||||
*
|
||||
* By default, logs are written to WRITEPATH . 'logs/'
|
||||
* Specify a different destination here, if desired.
|
||||
*/
|
||||
'path' => '',
|
||||
],
|
||||
|
||||
/*
|
||||
* The ChromeLoggerHandler requires the use of the Chrome web browser
|
||||
* and the ChromeLogger extension. Uncomment this block to use it.
|
||||
*/
|
||||
// 'CodeIgniter\Log\Handlers\ChromeLoggerHandler' => [
|
||||
// /*
|
||||
// * The log levels that this handler will handle.
|
||||
// */
|
||||
// 'handles' => ['critical', 'alert', 'emergency', 'debug',
|
||||
// 'error', 'info', 'notice', 'warning'],
|
||||
// ],
|
||||
|
||||
/*
|
||||
* The ErrorlogHandler writes the logs to PHP's native `error_log()` function.
|
||||
* Uncomment this block to use it.
|
||||
*/
|
||||
// 'CodeIgniter\Log\Handlers\ErrorlogHandler' => [
|
||||
// /* The log levels this handler can handle. */
|
||||
// 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
|
||||
//
|
||||
// /*
|
||||
// * The message type where the error should go. Can be 0 or 4, or use the
|
||||
// * class constants: `ErrorlogHandler::TYPE_OS` (0) or `ErrorlogHandler::TYPE_SAPI` (4)
|
||||
// */
|
||||
// 'messageType' => 0,
|
||||
// ],
|
||||
];
|
||||
}
|
||||
50
app/Config/Migrations.php
Normal file
50
app/Config/Migrations.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Migrations extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Enable/Disable Migrations
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Migrations are enabled by default.
|
||||
*
|
||||
* You should enable migrations whenever you intend to do a schema migration
|
||||
* and disable it back when you're done.
|
||||
*/
|
||||
public bool $enabled = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Migrations Table
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is the name of the table that will store the current migrations state.
|
||||
* When migrations runs it will store in a database table which migration
|
||||
* files have already been run.
|
||||
*/
|
||||
public string $table = 'migrations';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Timestamp Format
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is the format that will be used when creating new migrations
|
||||
* using the CLI command:
|
||||
* > php spark make:migration
|
||||
*
|
||||
* NOTE: if you set an unsupported format, migration runner will not find
|
||||
* your migration files.
|
||||
*
|
||||
* Supported formats:
|
||||
* - YmdHis_
|
||||
* - Y-m-d-His_
|
||||
* - Y_m_d_His_
|
||||
*/
|
||||
public string $timestampFormat = 'Y-m-d-His_';
|
||||
}
|
||||
534
app/Config/Mimes.php
Normal file
534
app/Config/Mimes.php
Normal file
@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
/**
|
||||
* This file contains an array of mime types. It is used by the
|
||||
* Upload class to help identify allowed file types.
|
||||
*
|
||||
* When more than one variation for an extension exist (like jpg, jpeg, etc)
|
||||
* the most common one should be first in the array to aid the guess*
|
||||
* methods. The same applies when more than one mime-type exists for a
|
||||
* single extension.
|
||||
*
|
||||
* When working with mime types, please make sure you have the ´fileinfo´
|
||||
* extension enabled to reliably detect the media types.
|
||||
*/
|
||||
class Mimes
|
||||
{
|
||||
/**
|
||||
* Map of extensions to mime types.
|
||||
*
|
||||
* @var array<string, list<string>|string>
|
||||
*/
|
||||
public static array $mimes = [
|
||||
'hqx' => [
|
||||
'application/mac-binhex40',
|
||||
'application/mac-binhex',
|
||||
'application/x-binhex40',
|
||||
'application/x-mac-binhex40',
|
||||
],
|
||||
'cpt' => 'application/mac-compactpro',
|
||||
'csv' => [
|
||||
'text/csv',
|
||||
'text/x-comma-separated-values',
|
||||
'text/comma-separated-values',
|
||||
'application/vnd.ms-excel',
|
||||
'application/x-csv',
|
||||
'text/x-csv',
|
||||
'application/csv',
|
||||
'application/excel',
|
||||
'application/vnd.msexcel',
|
||||
'text/plain',
|
||||
],
|
||||
'bin' => [
|
||||
'application/macbinary',
|
||||
'application/mac-binary',
|
||||
'application/octet-stream',
|
||||
'application/x-binary',
|
||||
'application/x-macbinary',
|
||||
],
|
||||
'dms' => 'application/octet-stream',
|
||||
'lha' => 'application/octet-stream',
|
||||
'lzh' => 'application/octet-stream',
|
||||
'exe' => [
|
||||
'application/octet-stream',
|
||||
'application/vnd.microsoft.portable-executable',
|
||||
'application/x-dosexec',
|
||||
'application/x-msdownload',
|
||||
],
|
||||
'class' => 'application/octet-stream',
|
||||
'psd' => [
|
||||
'application/x-photoshop',
|
||||
'image/vnd.adobe.photoshop',
|
||||
],
|
||||
'so' => 'application/octet-stream',
|
||||
'sea' => 'application/octet-stream',
|
||||
'dll' => 'application/octet-stream',
|
||||
'oda' => 'application/oda',
|
||||
'pdf' => [
|
||||
'application/pdf',
|
||||
'application/force-download',
|
||||
'application/x-download',
|
||||
],
|
||||
'ai' => [
|
||||
'application/pdf',
|
||||
'application/postscript',
|
||||
],
|
||||
'eps' => 'application/postscript',
|
||||
'ps' => 'application/postscript',
|
||||
'smi' => 'application/smil',
|
||||
'smil' => 'application/smil',
|
||||
'mif' => 'application/vnd.mif',
|
||||
'xls' => [
|
||||
'application/vnd.ms-excel',
|
||||
'application/msexcel',
|
||||
'application/x-msexcel',
|
||||
'application/x-ms-excel',
|
||||
'application/x-excel',
|
||||
'application/x-dos_ms_excel',
|
||||
'application/xls',
|
||||
'application/x-xls',
|
||||
'application/excel',
|
||||
'application/download',
|
||||
'application/vnd.ms-office',
|
||||
'application/msword',
|
||||
],
|
||||
'ppt' => [
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/powerpoint',
|
||||
'application/vnd.ms-office',
|
||||
'application/msword',
|
||||
],
|
||||
'pptx' => [
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
'wbxml' => 'application/wbxml',
|
||||
'wmlc' => 'application/wmlc',
|
||||
'dcr' => 'application/x-director',
|
||||
'dir' => 'application/x-director',
|
||||
'dxr' => 'application/x-director',
|
||||
'dvi' => 'application/x-dvi',
|
||||
'gtar' => 'application/x-gtar',
|
||||
'gz' => 'application/x-gzip',
|
||||
'gzip' => 'application/x-gzip',
|
||||
'php' => [
|
||||
'application/x-php',
|
||||
'application/x-httpd-php',
|
||||
'application/php',
|
||||
'text/php',
|
||||
'text/x-php',
|
||||
'application/x-httpd-php-source',
|
||||
],
|
||||
'php4' => 'application/x-httpd-php',
|
||||
'php3' => 'application/x-httpd-php',
|
||||
'phtml' => 'application/x-httpd-php',
|
||||
'phps' => 'application/x-httpd-php-source',
|
||||
'js' => [
|
||||
'application/x-javascript',
|
||||
'text/plain',
|
||||
],
|
||||
'swf' => 'application/x-shockwave-flash',
|
||||
'sit' => 'application/x-stuffit',
|
||||
'tar' => 'application/x-tar',
|
||||
'tgz' => [
|
||||
'application/x-tar',
|
||||
'application/x-gzip-compressed',
|
||||
],
|
||||
'z' => 'application/x-compress',
|
||||
'xhtml' => 'application/xhtml+xml',
|
||||
'xht' => 'application/xhtml+xml',
|
||||
'zip' => [
|
||||
'application/x-zip',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/s-compressed',
|
||||
'multipart/x-zip',
|
||||
],
|
||||
'rar' => [
|
||||
'application/vnd.rar',
|
||||
'application/x-rar',
|
||||
'application/rar',
|
||||
'application/x-rar-compressed',
|
||||
],
|
||||
'mid' => 'audio/midi',
|
||||
'midi' => 'audio/midi',
|
||||
'mpga' => 'audio/mpeg',
|
||||
'mp2' => 'audio/mpeg',
|
||||
'mp3' => [
|
||||
'audio/mpeg',
|
||||
'audio/mpg',
|
||||
'audio/mpeg3',
|
||||
'audio/mp3',
|
||||
],
|
||||
'aif' => [
|
||||
'audio/x-aiff',
|
||||
'audio/aiff',
|
||||
],
|
||||
'aiff' => [
|
||||
'audio/x-aiff',
|
||||
'audio/aiff',
|
||||
],
|
||||
'aifc' => 'audio/x-aiff',
|
||||
'ram' => 'audio/x-pn-realaudio',
|
||||
'rm' => 'audio/x-pn-realaudio',
|
||||
'rpm' => 'audio/x-pn-realaudio-plugin',
|
||||
'ra' => 'audio/x-realaudio',
|
||||
'rv' => 'video/vnd.rn-realvideo',
|
||||
'wav' => [
|
||||
'audio/x-wav',
|
||||
'audio/wave',
|
||||
'audio/wav',
|
||||
],
|
||||
'bmp' => [
|
||||
'image/bmp',
|
||||
'image/x-bmp',
|
||||
'image/x-bitmap',
|
||||
'image/x-xbitmap',
|
||||
'image/x-win-bitmap',
|
||||
'image/x-windows-bmp',
|
||||
'image/ms-bmp',
|
||||
'image/x-ms-bmp',
|
||||
'application/bmp',
|
||||
'application/x-bmp',
|
||||
'application/x-win-bitmap',
|
||||
],
|
||||
'gif' => 'image/gif',
|
||||
'jpg' => [
|
||||
'image/jpeg',
|
||||
'image/pjpeg',
|
||||
],
|
||||
'jpeg' => [
|
||||
'image/jpeg',
|
||||
'image/pjpeg',
|
||||
],
|
||||
'jpe' => [
|
||||
'image/jpeg',
|
||||
'image/pjpeg',
|
||||
],
|
||||
'jp2' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'j2k' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'jpf' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'jpg2' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'jpx' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'jpm' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'mj2' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'mjp2' => [
|
||||
'image/jp2',
|
||||
'video/mj2',
|
||||
'image/jpx',
|
||||
'image/jpm',
|
||||
],
|
||||
'png' => [
|
||||
'image/png',
|
||||
'image/x-png',
|
||||
],
|
||||
'webp' => 'image/webp',
|
||||
'tif' => 'image/tiff',
|
||||
'tiff' => 'image/tiff',
|
||||
'css' => [
|
||||
'text/css',
|
||||
'text/plain',
|
||||
],
|
||||
'html' => [
|
||||
'text/html',
|
||||
'text/plain',
|
||||
],
|
||||
'htm' => [
|
||||
'text/html',
|
||||
'text/plain',
|
||||
],
|
||||
'shtml' => [
|
||||
'text/html',
|
||||
'text/plain',
|
||||
],
|
||||
'txt' => 'text/plain',
|
||||
'text' => 'text/plain',
|
||||
'log' => [
|
||||
'text/plain',
|
||||
'text/x-log',
|
||||
],
|
||||
'rtx' => 'text/richtext',
|
||||
'rtf' => 'text/rtf',
|
||||
'xml' => [
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/plain',
|
||||
],
|
||||
'xsl' => [
|
||||
'application/xml',
|
||||
'text/xsl',
|
||||
'text/xml',
|
||||
],
|
||||
'mpeg' => 'video/mpeg',
|
||||
'mpg' => 'video/mpeg',
|
||||
'mpe' => 'video/mpeg',
|
||||
'qt' => 'video/quicktime',
|
||||
'mov' => 'video/quicktime',
|
||||
'avi' => [
|
||||
'video/x-msvideo',
|
||||
'video/msvideo',
|
||||
'video/avi',
|
||||
'application/x-troff-msvideo',
|
||||
],
|
||||
'movie' => 'video/x-sgi-movie',
|
||||
'doc' => [
|
||||
'application/msword',
|
||||
'application/vnd.ms-office',
|
||||
],
|
||||
'docx' => [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/zip',
|
||||
'application/msword',
|
||||
'application/x-zip',
|
||||
],
|
||||
'dot' => [
|
||||
'application/msword',
|
||||
'application/vnd.ms-office',
|
||||
],
|
||||
'dotx' => [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/zip',
|
||||
'application/msword',
|
||||
],
|
||||
'xlsx' => [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip',
|
||||
'application/vnd.ms-excel',
|
||||
'application/msword',
|
||||
'application/x-zip',
|
||||
],
|
||||
'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
|
||||
'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'word' => [
|
||||
'application/msword',
|
||||
'application/octet-stream',
|
||||
],
|
||||
'xl' => 'application/excel',
|
||||
'eml' => 'message/rfc822',
|
||||
'json' => [
|
||||
'application/json',
|
||||
'text/json',
|
||||
],
|
||||
'pem' => [
|
||||
'application/x-x509-user-cert',
|
||||
'application/x-pem-file',
|
||||
'application/octet-stream',
|
||||
],
|
||||
'p10' => [
|
||||
'application/x-pkcs10',
|
||||
'application/pkcs10',
|
||||
],
|
||||
'p12' => 'application/x-pkcs12',
|
||||
'p7a' => 'application/x-pkcs7-signature',
|
||||
'p7c' => [
|
||||
'application/pkcs7-mime',
|
||||
'application/x-pkcs7-mime',
|
||||
],
|
||||
'p7m' => [
|
||||
'application/pkcs7-mime',
|
||||
'application/x-pkcs7-mime',
|
||||
],
|
||||
'p7r' => 'application/x-pkcs7-certreqresp',
|
||||
'p7s' => 'application/pkcs7-signature',
|
||||
'crt' => [
|
||||
'application/x-x509-ca-cert',
|
||||
'application/x-x509-user-cert',
|
||||
'application/pkix-cert',
|
||||
],
|
||||
'crl' => [
|
||||
'application/pkix-crl',
|
||||
'application/pkcs-crl',
|
||||
],
|
||||
'der' => 'application/x-x509-ca-cert',
|
||||
'kdb' => 'application/octet-stream',
|
||||
'pgp' => 'application/pgp',
|
||||
'gpg' => 'application/gpg-keys',
|
||||
'sst' => 'application/octet-stream',
|
||||
'csr' => 'application/octet-stream',
|
||||
'rsa' => 'application/x-pkcs7',
|
||||
'cer' => [
|
||||
'application/pkix-cert',
|
||||
'application/x-x509-ca-cert',
|
||||
],
|
||||
'3g2' => 'video/3gpp2',
|
||||
'3gp' => [
|
||||
'video/3gp',
|
||||
'video/3gpp',
|
||||
],
|
||||
'mp4' => 'video/mp4',
|
||||
'm4a' => 'audio/x-m4a',
|
||||
'f4v' => [
|
||||
'video/mp4',
|
||||
'video/x-f4v',
|
||||
],
|
||||
'flv' => 'video/x-flv',
|
||||
'webm' => 'video/webm',
|
||||
'aac' => 'audio/x-acc',
|
||||
'm4u' => 'application/vnd.mpegurl',
|
||||
'm3u' => 'text/plain',
|
||||
'xspf' => 'application/xspf+xml',
|
||||
'vlc' => 'application/videolan',
|
||||
'wmv' => [
|
||||
'video/x-ms-wmv',
|
||||
'video/x-ms-asf',
|
||||
],
|
||||
'au' => 'audio/x-au',
|
||||
'ac3' => 'audio/ac3',
|
||||
'flac' => 'audio/x-flac',
|
||||
'ogg' => [
|
||||
'audio/ogg',
|
||||
'video/ogg',
|
||||
'application/ogg',
|
||||
],
|
||||
'kmz' => [
|
||||
'application/vnd.google-earth.kmz',
|
||||
'application/zip',
|
||||
'application/x-zip',
|
||||
],
|
||||
'kml' => [
|
||||
'application/vnd.google-earth.kml+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
],
|
||||
'ics' => 'text/calendar',
|
||||
'ical' => 'text/calendar',
|
||||
'zsh' => 'text/x-scriptzsh',
|
||||
'7zip' => [
|
||||
'application/x-compressed',
|
||||
'application/x-zip-compressed',
|
||||
'application/zip',
|
||||
'multipart/x-zip',
|
||||
],
|
||||
'cdr' => [
|
||||
'application/cdr',
|
||||
'application/coreldraw',
|
||||
'application/x-cdr',
|
||||
'application/x-coreldraw',
|
||||
'image/cdr',
|
||||
'image/x-cdr',
|
||||
'zz-application/zz-winassoc-cdr',
|
||||
],
|
||||
'wma' => [
|
||||
'audio/x-ms-wma',
|
||||
'video/x-ms-asf',
|
||||
],
|
||||
'jar' => [
|
||||
'application/java-archive',
|
||||
'application/x-java-application',
|
||||
'application/x-jar',
|
||||
'application/x-compressed',
|
||||
],
|
||||
'svg' => [
|
||||
'image/svg+xml',
|
||||
'image/svg',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
],
|
||||
'vcf' => 'text/x-vcard',
|
||||
'srt' => [
|
||||
'text/srt',
|
||||
'text/plain',
|
||||
],
|
||||
'vtt' => [
|
||||
'text/vtt',
|
||||
'text/plain',
|
||||
],
|
||||
'ico' => [
|
||||
'image/x-icon',
|
||||
'image/x-ico',
|
||||
'image/vnd.microsoft.icon',
|
||||
],
|
||||
'stl' => [
|
||||
'application/sla',
|
||||
'application/vnd.ms-pki.stl',
|
||||
'application/x-navistyle',
|
||||
'model/stl',
|
||||
'application/octet-stream',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Attempts to determine the best mime type for the given file extension.
|
||||
*
|
||||
* @return string|null The mime type found, or none if unable to determine.
|
||||
*/
|
||||
public static function guessTypeFromExtension(string $extension)
|
||||
{
|
||||
$extension = trim(strtolower($extension), '. ');
|
||||
|
||||
if (! array_key_exists($extension, static::$mimes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array(static::$mimes[$extension]) ? static::$mimes[$extension][0] : static::$mimes[$extension];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to determine the best file extension for a given mime type.
|
||||
*
|
||||
* @param string|null $proposedExtension - default extension (in case there is more than one with the same mime type)
|
||||
*
|
||||
* @return string|null The extension determined, or null if unable to match.
|
||||
*/
|
||||
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null)
|
||||
{
|
||||
$type = trim(strtolower($type), '. ');
|
||||
|
||||
$proposedExtension = trim(strtolower($proposedExtension ?? ''));
|
||||
|
||||
if (
|
||||
$proposedExtension !== ''
|
||||
&& array_key_exists($proposedExtension, static::$mimes)
|
||||
&& in_array($type, (array) static::$mimes[$proposedExtension], true)
|
||||
) {
|
||||
// The detected mime type matches with the proposed extension.
|
||||
return $proposedExtension;
|
||||
}
|
||||
|
||||
// Reverse check the mime type list if no extension was proposed.
|
||||
// This search is order sensitive!
|
||||
foreach (static::$mimes as $ext => $types) {
|
||||
if (in_array($type, (array) $types, true)) {
|
||||
return $ext;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
82
app/Config/Modules.php
Normal file
82
app/Config/Modules.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Modules\Modules as BaseModules;
|
||||
|
||||
/**
|
||||
* Modules Configuration.
|
||||
*
|
||||
* NOTE: This class is required prior to Autoloader instantiation,
|
||||
* and does not extend BaseConfig.
|
||||
*/
|
||||
class Modules extends BaseModules
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Enable Auto-Discovery?
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If true, then auto-discovery will happen across all elements listed in
|
||||
* $aliases below. If false, no auto-discovery will happen at all,
|
||||
* giving a slight performance boost.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $enabled = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Enable Auto-Discovery Within Composer Packages?
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If true, then auto-discovery will happen across all namespaces loaded
|
||||
* by Composer, as well as the namespaces configured locally.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $discoverInComposer = true;
|
||||
|
||||
/**
|
||||
* The Composer package list for Auto-Discovery
|
||||
* This setting is optional.
|
||||
*
|
||||
* E.g.:
|
||||
* [
|
||||
* 'only' => [
|
||||
* // List up all packages to auto-discover
|
||||
* 'codeigniter4/shield',
|
||||
* ],
|
||||
* ]
|
||||
* or
|
||||
* [
|
||||
* 'exclude' => [
|
||||
* // List up packages to exclude.
|
||||
* 'pestphp/pest',
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* @var array{only?: list<string>, exclude?: list<string>}
|
||||
*/
|
||||
public $composerPackages = [];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Auto-Discovery Rules
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Aliases list of all discovery classes that will be active and used during
|
||||
* the current application request.
|
||||
*
|
||||
* If it is not listed, only the base application elements will be used.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $aliases = [
|
||||
'events',
|
||||
'filters',
|
||||
'registrars',
|
||||
'routes',
|
||||
'services',
|
||||
];
|
||||
}
|
||||
30
app/Config/Optimize.php
Normal file
30
app/Config/Optimize.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
/**
|
||||
* Optimization Configuration.
|
||||
*
|
||||
* NOTE: This class does not extend BaseConfig for performance reasons.
|
||||
* So you cannot replace the property values with Environment Variables.
|
||||
*/
|
||||
class Optimize
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Config Caching
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* @see https://codeigniter.com/user_guide/concepts/factories.html#config-caching
|
||||
*/
|
||||
public bool $configCacheEnabled = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Config Caching
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* @see https://codeigniter.com/user_guide/concepts/autoloader.html#file-locator-caching
|
||||
*/
|
||||
public bool $locatorCacheEnabled = false;
|
||||
}
|
||||
38
app/Config/Pager.php
Normal file
38
app/Config/Pager.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Pager extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Templates
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Pagination links are rendered out using views to configure their
|
||||
* appearance. This array contains aliases and the view names to
|
||||
* use when rendering the links.
|
||||
*
|
||||
* Within each view, the Pager object will be available as $pager,
|
||||
* and the desired group as $pagerGroup;
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $templates = [
|
||||
'default_full' => 'CodeIgniter\Pager\Views\default_full',
|
||||
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
|
||||
'default_head' => 'CodeIgniter\Pager\Views\default_head',
|
||||
'cb_full' => 'Pagers/cb_full',
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Items Per Page
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The default number of results shown in a single page.
|
||||
*/
|
||||
public int $perPage = 20;
|
||||
}
|
||||
78
app/Config/Paths.php
Normal file
78
app/Config/Paths.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
/**
|
||||
* Paths
|
||||
*
|
||||
* Holds the paths that are used by the system to
|
||||
* locate the main directories, app, system, etc.
|
||||
*
|
||||
* Modifying these allows you to restructure your application,
|
||||
* share a system folder between multiple applications, and more.
|
||||
*
|
||||
* All paths are relative to the project's root folder.
|
||||
*
|
||||
* NOTE: This class is required prior to Autoloader instantiation,
|
||||
* and does not extend BaseConfig.
|
||||
*/
|
||||
class Paths
|
||||
{
|
||||
/**
|
||||
* ---------------------------------------------------------------
|
||||
* SYSTEM FOLDER NAME
|
||||
* ---------------------------------------------------------------
|
||||
*
|
||||
* This must contain the name of your "system" folder. Include
|
||||
* the path if the folder is not in the same directory as this file.
|
||||
*/
|
||||
public string $systemDirectory = __DIR__ . '/../../vendor/codeigniter4/framework/system';
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------------
|
||||
* APPLICATION FOLDER NAME
|
||||
* ---------------------------------------------------------------
|
||||
*
|
||||
* If you want this front controller to use a different "app"
|
||||
* folder than the default one you can set its name here. The folder
|
||||
* can also be renamed or relocated anywhere on your server. If
|
||||
* you do, use a full server path.
|
||||
*
|
||||
* @see http://codeigniter.com/user_guide/general/managing_apps.html
|
||||
*/
|
||||
public string $appDirectory = __DIR__ . '/..';
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------------
|
||||
* WRITABLE DIRECTORY NAME
|
||||
* ---------------------------------------------------------------
|
||||
*
|
||||
* This variable must contain the name of your "writable" directory.
|
||||
* The writable directory allows you to group all directories that
|
||||
* need write permission to a single place that can be tucked away
|
||||
* for maximum security, keeping it out of the app and/or
|
||||
* system directories.
|
||||
*/
|
||||
public string $writableDirectory = __DIR__ . '/../../writable';
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------------
|
||||
* TESTS DIRECTORY NAME
|
||||
* ---------------------------------------------------------------
|
||||
*
|
||||
* This variable must contain the name of your "tests" directory.
|
||||
*/
|
||||
public string $testsDirectory = __DIR__ . '/../../tests';
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------------
|
||||
* VIEW DIRECTORY NAME
|
||||
* ---------------------------------------------------------------
|
||||
*
|
||||
* This variable must contain the name of the directory that
|
||||
* contains the view files used by your application. By
|
||||
* default this is in `app/Views`. This value
|
||||
* is used when no value is provided to `Services::renderer()`.
|
||||
*/
|
||||
public string $viewDirectory = __DIR__ . '/../Views';
|
||||
}
|
||||
28
app/Config/Publisher.php
Normal file
28
app/Config/Publisher.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\Publisher as BasePublisher;
|
||||
|
||||
/**
|
||||
* Publisher Configuration
|
||||
*
|
||||
* Defines basic security restrictions for the Publisher class
|
||||
* to prevent abuse by injecting malicious files into a project.
|
||||
*/
|
||||
class Publisher extends BasePublisher
|
||||
{
|
||||
/**
|
||||
* A list of allowed destinations with a (pseudo-)regex
|
||||
* of allowed files for each destination.
|
||||
* Attempts to publish to directories not in this list will
|
||||
* result in a PublisherException. Files that do no fit the
|
||||
* pattern will cause copy/merge to fail.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public $restrictions = [
|
||||
ROOTPATH => '*',
|
||||
FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i',
|
||||
];
|
||||
}
|
||||
159
app/Config/Routes.php
Normal file
159
app/Config/Routes.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
/**
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', 'Auth\LoginController::index');
|
||||
|
||||
// Authentication
|
||||
$routes->group('', static function ($routes) {
|
||||
$routes->get('login', 'Auth\LoginController::index');
|
||||
$routes->post('login', 'Auth\LoginController::authenticate');
|
||||
$routes->get('logout', 'Auth\LoginController::logout');
|
||||
|
||||
$routes->get('register', 'Auth\RegisterController::index');
|
||||
$routes->post('register', 'Auth\RegisterController::store');
|
||||
$routes->get('verify/(:segment)', 'Auth\RegisterController::verify/$1');
|
||||
|
||||
$routes->get('forgot-password', 'Auth\PasswordController::forgot');
|
||||
$routes->post('forgot-password', 'Auth\PasswordController::sendResetLink');
|
||||
$routes->get('reset-password/(:segment)', 'Auth\PasswordController::reset/$1');
|
||||
$routes->post('reset-password/(:segment)', 'Auth\PasswordController::updatePassword/$1');
|
||||
});
|
||||
|
||||
// App modules protected by auth
|
||||
$routes->group('', ['filter' => 'auth'], static function ($routes) {
|
||||
$routes->group('dashboard', ['filter' => 'workspace'], static function ($routes) {
|
||||
$routes->get('/', 'Dashboard\DashboardController::index');
|
||||
$routes->post('store', 'Dashboard\DashboardController::store');
|
||||
$routes->get('view/(:num)', 'Dashboard\DashboardController::viewBoard/$1');
|
||||
$routes->get('settings/(:num)', 'Dashboard\DashboardController::settings/$1');
|
||||
$routes->post('settings/(:num)', 'Dashboard\DashboardController::updateSettings/$1');
|
||||
$routes->post('delete/(:num)', 'Dashboard\DashboardController::delete/$1');
|
||||
$routes->post('pin/(:num)', 'Dashboard\DashboardController::pinToggle/$1');
|
||||
$routes->post('(:num)/layout', 'Dashboard\DashboardController::saveLayout/$1');
|
||||
$routes->post('(:num)/widget', 'Dashboard\DashboardController::addWidget/$1');
|
||||
$routes->post('widget/(:num)/delete', 'Dashboard\DashboardController::removeWidget/$1');
|
||||
});
|
||||
$routes->get('profile', 'ProfileController::index');
|
||||
$routes->post('profile/update', 'ProfileController::update');
|
||||
$routes->post('profile/password', 'ProfileController::changePassword');
|
||||
$routes->post('profile/generate-api-token', 'ProfileController::generateApiToken');
|
||||
|
||||
$routes->group('workspace', static function ($routes) {
|
||||
$routes->get('/', 'Workspace\WorkspaceController::index');
|
||||
$routes->get('create', 'Workspace\WorkspaceController::create');
|
||||
$routes->post('store', 'Workspace\WorkspaceController::store');
|
||||
$routes->get('switch/(:num)', 'Workspace\WorkspaceController::switch/$1');
|
||||
});
|
||||
|
||||
$routes->group('workspace', ['filter' => 'workspace'], static function ($routes) {
|
||||
$routes->get('settings/(:num)', 'Workspace\WorkspaceController::settings/$1');
|
||||
$routes->post('update/(:num)', 'Workspace\WorkspaceController::update/$1');
|
||||
$routes->post('delete/(:num)', 'Workspace\WorkspaceController::delete/$1');
|
||||
|
||||
$routes->get('members/(:num)', 'Workspace\WorkspaceMemberController::index/$1');
|
||||
$routes->post('members/(:num)/role/(:num)', 'Workspace\WorkspaceMemberController::updateRole/$1/$2');
|
||||
$routes->post('members/(:num)/remove/(:num)', 'Workspace\WorkspaceMemberController::remove/$1/$2');
|
||||
|
||||
$routes->get('invite/(:num)', 'Workspace\WorkspaceInvitationController::index/$1');
|
||||
$routes->post('invite/(:num)', 'Workspace\WorkspaceInvitationController::send/$1');
|
||||
$routes->post('invite/(:num)/cancel/(:num)', 'Workspace\WorkspaceInvitationController::cancel/$1/$2');
|
||||
});
|
||||
$routes->group('datasource', static function ($routes) {
|
||||
$routes->get('/', 'DataSource\DataSourceController::index', ['filter' => 'workspace']);
|
||||
$routes->get('view/(:num)', 'DataSource\DataSourceController::show/$1', ['filter' => 'workspace']);
|
||||
$routes->get('view/(:num)/proof', 'DataSource\DataSourceController::proof/$1', ['filter' => 'workspace']);
|
||||
$routes->get('create', 'DataSource\DataSourceController::create', ['filter' => 'workspace']);
|
||||
$routes->post('store', 'DataSource\DataSourceController::store', ['filter' => 'workspace']);
|
||||
$routes->get('edit/(:num)', 'DataSource\DataSourceController::edit/$1', ['filter' => 'workspace']);
|
||||
$routes->post('update/(:num)', 'DataSource\DataSourceController::update/$1', ['filter' => 'workspace']);
|
||||
$routes->post('delete/(:num)', 'DataSource\DataSourceController::delete/$1', ['filter' => 'workspace']);
|
||||
$routes->post('test', 'DataSource\DataSourceController::test', ['filter' => 'workspace']);
|
||||
$routes->get('(:num)/schema', 'DataSource\DataSourceController::schema/$1', ['filter' => 'workspace']);
|
||||
});
|
||||
$routes->group('query', static function ($routes) {
|
||||
$routes->get('/', 'Query\QueryController::index', ['filter' => 'workspace']);
|
||||
$routes->get('create', 'Query\QueryController::create', ['filter' => 'workspace']);
|
||||
$routes->post('store', 'Query\QueryController::store', ['filter' => 'workspace']);
|
||||
$routes->get('view/(:num)', 'Query\QueryController::show/$1', ['filter' => 'workspace']);
|
||||
$routes->get('edit/(:num)', 'Query\QueryController::edit/$1', ['filter' => 'workspace']);
|
||||
$routes->post('update/(:num)', 'Query\QueryController::update/$1', ['filter' => 'workspace']);
|
||||
$routes->post('execute', 'Query\QueryController::execute', ['filter' => 'workspace']);
|
||||
$routes->post('detect-variables', 'Query\QueryController::detectVariables', ['filter' => 'workspace']);
|
||||
});
|
||||
$routes->group('chart', static function ($routes) {
|
||||
$routes->get('/', 'Chart\ChartController::index', ['filter' => 'workspace']);
|
||||
$routes->get('create', 'Chart\ChartController::create', ['filter' => 'workspace']);
|
||||
$routes->post('store', 'Chart\ChartController::store', ['filter' => 'workspace']);
|
||||
$routes->get('edit/(:num)', 'Chart\ChartController::edit/$1', ['filter' => 'workspace']);
|
||||
$routes->post('update/(:num)', 'Chart\ChartController::update/$1', ['filter' => 'workspace']);
|
||||
$routes->post('delete/(:num)', 'Chart\ChartController::delete/$1', ['filter' => 'workspace']);
|
||||
$routes->post('duplicate/(:num)', 'Chart\ChartController::duplicate/$1', ['filter' => 'workspace']);
|
||||
$routes->get('saved-query/(:num)/variables', 'Chart\ChartController::savedQueryVariables/$1', ['filter' => 'workspace']);
|
||||
$routes->post('preview-query', 'Chart\ChartController::previewQuery', ['filter' => 'workspace']);
|
||||
$routes->post('preview-render', 'Chart\ChartController::previewRender', ['filter' => 'workspace']);
|
||||
$routes->post('(:num)/data', 'Chart\ChartController::data/$1', ['filter' => 'workspace']);
|
||||
$routes->get('(:num)/export', 'Chart\ChartController::export/$1', ['filter' => 'workspace']);
|
||||
});
|
||||
|
||||
$routes->group('alert', ['filter' => 'workspace'], static function ($routes) {
|
||||
$routes->get('/', 'Alert\AlertController::index');
|
||||
$routes->get('create', 'Alert\AlertController::create');
|
||||
$routes->post('store', 'Alert\AlertController::store');
|
||||
$routes->get('edit/(:num)', 'Alert\AlertController::edit/$1');
|
||||
$routes->post('update/(:num)', 'Alert\AlertController::update/$1');
|
||||
$routes->post('delete/(:num)', 'Alert\AlertController::delete/$1');
|
||||
$routes->post('mute/(:num)', 'Alert\AlertController::mute/$1');
|
||||
$routes->post('unmute/(:num)', 'Alert\AlertController::unmute/$1');
|
||||
$routes->get('history/(:num)', 'Alert\AlertController::history/$1');
|
||||
});
|
||||
|
||||
$routes->get('sharing/links', 'Share\LinkController::listResource', ['filter' => 'workspace']);
|
||||
$routes->post('sharing/generate', 'Share\LinkController::generate', ['filter' => 'workspace']);
|
||||
$routes->post('sharing/revoke/(:num)', 'Share\LinkController::revoke/$1', ['filter' => 'workspace']);
|
||||
|
||||
$routes->group('admin/users', ['filter' => 'role:superadmin'], static function ($routes) {
|
||||
$routes->get('/', 'Admin\UserController::index');
|
||||
$routes->get('edit/(:num)', 'Admin\UserController::edit/$1');
|
||||
$routes->post('update/(:num)', 'Admin\UserController::update/$1');
|
||||
});
|
||||
|
||||
$routes->group('admin', ['filter' => 'role:superadmin'], static function ($routes) {
|
||||
$routes->get('settings', 'Admin\SettingsController::index');
|
||||
$routes->post('settings', 'Admin\SettingsController::update');
|
||||
});
|
||||
|
||||
$routes->group('audit', ['filter' => ['workspace', 'workspaceadmin']], static function ($routes) {
|
||||
$routes->get('/', 'Audit\AuditController::index');
|
||||
$routes->get('detail/(:num)', 'Audit\AuditController::detail/$1');
|
||||
});
|
||||
|
||||
$routes->group('settings', ['filter' => 'workspace'], static function ($routes) {
|
||||
$routes->get('workspace', 'Settings\SettingsController::workspace');
|
||||
$routes->post('workspace', 'Settings\SettingsController::saveWorkspace');
|
||||
});
|
||||
|
||||
$routes->group('settings', ['filter' => ['workspace', 'workspaceadmin']], static function ($routes) {
|
||||
$routes->get('notifications', 'Settings\SettingsController::notifications');
|
||||
$routes->post('notifications/slack-webhook', 'Settings\SettingsController::saveSlackWebhook');
|
||||
$routes->post('notifications/smtp-test', 'Settings\SettingsController::testSmtp');
|
||||
$routes->post('notifications/slack-test', 'Settings\SettingsController::testSlack');
|
||||
});
|
||||
|
||||
$routes->post('settings/theme', 'Settings\SettingsController::saveTheme');
|
||||
});
|
||||
|
||||
$routes->get('invite/(:segment)', 'Workspace\WorkspaceInvitationController::accept/$1');
|
||||
|
||||
// Public share (no auth)
|
||||
$routes->get('share/(:segment)', 'Share\PublicController::show/$1');
|
||||
$routes->post('share/(:segment)/unlock', 'Share\PublicController::unlock/$1');
|
||||
$routes->post('share/(:segment)/chart/(:num)/data', 'Share\PublicController::chartData/$1/$2');
|
||||
|
||||
// API v1 routes
|
||||
$routes->group('api/v1', ['filter' => 'apiauth'], static function ($routes) {
|
||||
$routes->options('(:any)', static fn() => service('response')->setStatusCode(200));
|
||||
});
|
||||
140
app/Config/Routing.php
Normal file
140
app/Config/Routing.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of CodeIgniter 4 framework.
|
||||
*
|
||||
* (c) CodeIgniter Foundation <admin@codeigniter.com>
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\Routing as BaseRouting;
|
||||
|
||||
/**
|
||||
* Routing configuration
|
||||
*/
|
||||
class Routing extends BaseRouting
|
||||
{
|
||||
/**
|
||||
* For Defined Routes.
|
||||
* An array of files that contain route definitions.
|
||||
* Route files are read in order, with the first match
|
||||
* found taking precedence.
|
||||
*
|
||||
* Default: APPPATH . 'Config/Routes.php'
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $routeFiles = [
|
||||
APPPATH . 'Config/Routes.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* For Defined Routes and Auto Routing.
|
||||
* The default namespace to use for Controllers when no other
|
||||
* namespace has been specified.
|
||||
*
|
||||
* Default: 'App\Controllers'
|
||||
*/
|
||||
public string $defaultNamespace = 'App\Controllers';
|
||||
|
||||
/**
|
||||
* For Auto Routing.
|
||||
* The default controller to use when no other controller has been
|
||||
* specified.
|
||||
*
|
||||
* Default: 'Home'
|
||||
*/
|
||||
public string $defaultController = 'Home';
|
||||
|
||||
/**
|
||||
* For Defined Routes and Auto Routing.
|
||||
* The default method to call on the controller when no other
|
||||
* method has been set in the route.
|
||||
*
|
||||
* Default: 'index'
|
||||
*/
|
||||
public string $defaultMethod = 'index';
|
||||
|
||||
/**
|
||||
* For Auto Routing.
|
||||
* Whether to translate dashes in URIs for controller/method to underscores.
|
||||
* Primarily useful when using the auto-routing.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
public bool $translateURIDashes = false;
|
||||
|
||||
/**
|
||||
* Sets the class/method that should be called if routing doesn't
|
||||
* find a match. It can be the controller/method name like: Users::index
|
||||
*
|
||||
* This setting is passed to the Router class and handled there.
|
||||
*
|
||||
* If you want to use a closure, you will have to set it in the
|
||||
* routes file by calling:
|
||||
*
|
||||
* $routes->set404Override(function() {
|
||||
* // Do something here
|
||||
* });
|
||||
*
|
||||
* Example:
|
||||
* public $override404 = 'App\Errors::show404';
|
||||
*/
|
||||
public ?string $override404 = null;
|
||||
|
||||
/**
|
||||
* If TRUE, the system will attempt to match the URI against
|
||||
* Controllers by matching each segment against folders/files
|
||||
* in APPPATH/Controllers, when a match wasn't found against
|
||||
* defined routes.
|
||||
*
|
||||
* If FALSE, will stop searching and do NO automatic routing.
|
||||
*/
|
||||
public bool $autoRoute = false;
|
||||
|
||||
/**
|
||||
* For Defined Routes.
|
||||
* If TRUE, will enable the use of the 'prioritize' option
|
||||
* when defining routes.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
public bool $prioritize = false;
|
||||
|
||||
/**
|
||||
* For Defined Routes.
|
||||
* If TRUE, matched multiple URI segments will be passed as one parameter.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
public bool $multipleSegmentsOneParam = false;
|
||||
|
||||
/**
|
||||
* For Auto Routing (Improved).
|
||||
* Map of URI segments and namespaces.
|
||||
*
|
||||
* The key is the first URI segment. The value is the controller namespace.
|
||||
* E.g.,
|
||||
* [
|
||||
* 'blog' => 'Acme\Blog\Controllers',
|
||||
* ]
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $moduleRoutes = [];
|
||||
|
||||
/**
|
||||
* For Auto Routing (Improved).
|
||||
* Whether to translate dashes in URIs for controller/method to CamelCase.
|
||||
* E.g., blog-controller -> BlogController
|
||||
*
|
||||
* If you enable this, $translateURIDashes is ignored.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
public bool $translateUriToCamelCase = true;
|
||||
}
|
||||
86
app/Config/Security.php
Normal file
86
app/Config/Security.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Security extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Protection Method
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Protection Method for Cross Site Request Forgery protection.
|
||||
*
|
||||
* @var string 'cookie' or 'session'
|
||||
*/
|
||||
public string $csrfProtection = 'cookie';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Token Randomization
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Randomize the CSRF Token for added security.
|
||||
*/
|
||||
public bool $tokenRandomize = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Token Name
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Token name for Cross Site Request Forgery protection.
|
||||
*/
|
||||
public string $tokenName = 'csrf_test_name';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Header Name
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Header name for Cross Site Request Forgery protection.
|
||||
*/
|
||||
public string $headerName = 'X-CSRF-TOKEN';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Cookie Name
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Cookie name for Cross Site Request Forgery protection.
|
||||
*/
|
||||
public string $cookieName = 'csrf_cookie_name';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Expires
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Expiration time for Cross Site Request Forgery protection cookie.
|
||||
*
|
||||
* Defaults to two hours (in seconds).
|
||||
*/
|
||||
public int $expires = 7200;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Regenerate
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Regenerate CSRF Token on every submission.
|
||||
*/
|
||||
public bool $regenerate = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* CSRF Redirect
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Redirect to previous page with error on failure.
|
||||
*
|
||||
* @see https://codeigniter4.github.io/userguide/libraries/security.html#redirection-on-failure
|
||||
*/
|
||||
public bool $redirect = (ENVIRONMENT === 'production');
|
||||
}
|
||||
32
app/Config/Services.php
Normal file
32
app/Config/Services.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseService;
|
||||
|
||||
/**
|
||||
* Services Configuration file.
|
||||
*
|
||||
* Services are simply other classes/libraries that the system uses
|
||||
* to do its job. This is used by CodeIgniter to allow the core of the
|
||||
* framework to be swapped out easily without affecting the usage within
|
||||
* the rest of your application.
|
||||
*
|
||||
* This file holds any application-specific services, or service overrides
|
||||
* that you might need. An example has been included with the general
|
||||
* method format you should use for your service methods. For more examples,
|
||||
* see the core Services file at system/Config/Services.php.
|
||||
*/
|
||||
class Services extends BaseService
|
||||
{
|
||||
/*
|
||||
* public static function example($getShared = true)
|
||||
* {
|
||||
* if ($getShared) {
|
||||
* return static::getSharedInstance('example');
|
||||
* }
|
||||
*
|
||||
* return new \CodeIgniter\Example();
|
||||
* }
|
||||
*/
|
||||
}
|
||||
127
app/Config/Session.php
Normal file
127
app/Config/Session.php
Normal file
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Session\Handlers\BaseHandler;
|
||||
use CodeIgniter\Session\Handlers\FileHandler;
|
||||
|
||||
class Session extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Driver
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The session storage driver to use:
|
||||
* - `CodeIgniter\Session\Handlers\FileHandler`
|
||||
* - `CodeIgniter\Session\Handlers\DatabaseHandler`
|
||||
* - `CodeIgniter\Session\Handlers\MemcachedHandler`
|
||||
* - `CodeIgniter\Session\Handlers\RedisHandler`
|
||||
*
|
||||
* @var class-string<BaseHandler>
|
||||
*/
|
||||
public string $driver = FileHandler::class;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Cookie Name
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The session cookie name, must contain only [0-9a-z_-] characters
|
||||
*/
|
||||
public string $cookieName = 'chartboard_session';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Expiration
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The number of SECONDS you want the session to last.
|
||||
* Setting to 0 (zero) means expire when the browser is closed.
|
||||
*/
|
||||
public int $expiration = 14400;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Save Path
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The location to save sessions to and is driver dependent.
|
||||
*
|
||||
* For the 'files' driver, it's a path to a writable directory.
|
||||
* WARNING: Only absolute paths are supported!
|
||||
*
|
||||
* For the 'database' driver, it's a table name.
|
||||
* Please read up the manual for the format with other session drivers.
|
||||
*
|
||||
* IMPORTANT: You are REQUIRED to set a valid save path!
|
||||
*/
|
||||
public string $savePath = WRITEPATH . 'session';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Match IP
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Whether to match the user's IP address when reading the session data.
|
||||
*
|
||||
* WARNING: If you're using the database driver, don't forget to update
|
||||
* your session table's PRIMARY KEY when changing this setting.
|
||||
*/
|
||||
public bool $matchIP = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Time to Update
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* How many seconds between CI regenerating the session ID.
|
||||
*/
|
||||
public int $timeToUpdate = 300;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Regenerate Destroy
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Whether to destroy session data associated with the old session ID
|
||||
* when auto-regenerating the session ID. When set to FALSE, the data
|
||||
* will be later deleted by the garbage collector.
|
||||
*/
|
||||
public bool $regenerateDestroy = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Session Database Group
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* DB Group for the database session.
|
||||
*/
|
||||
public ?string $DBGroup = null;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Lock Retry Interval (microseconds)
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is used for RedisHandler.
|
||||
*
|
||||
* Time (microseconds) to wait if lock cannot be acquired.
|
||||
* The default is 100,000 microseconds (= 0.1 seconds).
|
||||
*/
|
||||
public int $lockRetryInterval = 100_000;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Lock Max Retries
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* This is used for RedisHandler.
|
||||
*
|
||||
* Maximum number of lock acquisition attempts.
|
||||
* The default is 300 times. That is lock timeout is about 30 (0.1 * 300)
|
||||
* seconds.
|
||||
*/
|
||||
public int $lockMaxRetries = 300;
|
||||
}
|
||||
122
app/Config/Toolbar.php
Normal file
122
app/Config/Toolbar.php
Normal file
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Database;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Events;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Files;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Logs;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Routes;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Timers;
|
||||
use CodeIgniter\Debug\Toolbar\Collectors\Views;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Debug Toolbar
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The Debug Toolbar provides a way to see information about the performance
|
||||
* and state of your application during that page display. By default it will
|
||||
* NOT be displayed under production environments, and will only display if
|
||||
* `CI_DEBUG` is true, since if it's not, there's not much to display anyway.
|
||||
*/
|
||||
class Toolbar extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Toolbar Collectors
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* List of toolbar collectors that will be called when Debug Toolbar
|
||||
* fires up and collects data from.
|
||||
*
|
||||
* @var list<class-string>
|
||||
*/
|
||||
public array $collectors = [
|
||||
Timers::class,
|
||||
Database::class,
|
||||
Logs::class,
|
||||
Views::class,
|
||||
// \CodeIgniter\Debug\Toolbar\Collectors\Cache::class,
|
||||
Files::class,
|
||||
Routes::class,
|
||||
Events::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Collect Var Data
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If set to false var data from the views will not be collected. Useful to
|
||||
* avoid high memory usage when there are lots of data passed to the view.
|
||||
*/
|
||||
public bool $collectVarData = true;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Max History
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* `$maxHistory` sets a limit on the number of past requests that are stored,
|
||||
* helping to conserve file space used to store them. You can set it to
|
||||
* 0 (zero) to not have any history stored, or -1 for unlimited history.
|
||||
*/
|
||||
public int $maxHistory = 20;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Toolbar Views Path
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* The full path to the the views that are used by the toolbar.
|
||||
* This MUST have a trailing slash.
|
||||
*/
|
||||
public string $viewsPath = SYSTEMPATH . 'Debug/Toolbar/Views/';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Max Queries
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* If the Database Collector is enabled, it will log every query that the
|
||||
* the system generates so they can be displayed on the toolbar's timeline
|
||||
* and in the query log. This can lead to memory issues in some instances
|
||||
* with hundreds of queries.
|
||||
*
|
||||
* `$maxQueries` defines the maximum amount of queries that will be stored.
|
||||
*/
|
||||
public int $maxQueries = 100;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Watched Directories
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Contains an array of directories that will be watched for changes and
|
||||
* used to determine if the hot-reload feature should reload the page or not.
|
||||
* We restrict the values to keep performance as high as possible.
|
||||
*
|
||||
* NOTE: The ROOTPATH will be prepended to all values.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $watchedDirectories = [
|
||||
'app',
|
||||
];
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Watched File Extensions
|
||||
* --------------------------------------------------------------------------
|
||||
*
|
||||
* Contains an array of file extensions that will be watched for changes and
|
||||
* used to determine if the hot-reload feature should reload the page or not.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $watchedExtensions = [
|
||||
'php', 'css', 'js', 'html', 'svg', 'json', 'env',
|
||||
];
|
||||
}
|
||||
252
app/Config/UserAgents.php
Normal file
252
app/Config/UserAgents.php
Normal file
@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* User Agents
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* This file contains four arrays of user agent data. It is used by the
|
||||
* User Agent Class to help identify browser, platform, robot, and
|
||||
* mobile device data. The array keys are used to identify the device
|
||||
* and the array values are used to set the actual name of the item.
|
||||
*/
|
||||
class UserAgents extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* OS Platforms
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $platforms = [
|
||||
'windows nt 10.0' => 'Windows 10',
|
||||
'windows nt 6.3' => 'Windows 8.1',
|
||||
'windows nt 6.2' => 'Windows 8',
|
||||
'windows nt 6.1' => 'Windows 7',
|
||||
'windows nt 6.0' => 'Windows Vista',
|
||||
'windows nt 5.2' => 'Windows 2003',
|
||||
'windows nt 5.1' => 'Windows XP',
|
||||
'windows nt 5.0' => 'Windows 2000',
|
||||
'windows nt 4.0' => 'Windows NT 4.0',
|
||||
'winnt4.0' => 'Windows NT 4.0',
|
||||
'winnt 4.0' => 'Windows NT',
|
||||
'winnt' => 'Windows NT',
|
||||
'windows 98' => 'Windows 98',
|
||||
'win98' => 'Windows 98',
|
||||
'windows 95' => 'Windows 95',
|
||||
'win95' => 'Windows 95',
|
||||
'windows phone' => 'Windows Phone',
|
||||
'windows' => 'Unknown Windows OS',
|
||||
'android' => 'Android',
|
||||
'blackberry' => 'BlackBerry',
|
||||
'iphone' => 'iOS',
|
||||
'ipad' => 'iOS',
|
||||
'ipod' => 'iOS',
|
||||
'os x' => 'Mac OS X',
|
||||
'ppc mac' => 'Power PC Mac',
|
||||
'freebsd' => 'FreeBSD',
|
||||
'ppc' => 'Macintosh',
|
||||
'linux' => 'Linux',
|
||||
'debian' => 'Debian',
|
||||
'sunos' => 'Sun Solaris',
|
||||
'beos' => 'BeOS',
|
||||
'apachebench' => 'ApacheBench',
|
||||
'aix' => 'AIX',
|
||||
'irix' => 'Irix',
|
||||
'osf' => 'DEC OSF',
|
||||
'hp-ux' => 'HP-UX',
|
||||
'netbsd' => 'NetBSD',
|
||||
'bsdi' => 'BSDi',
|
||||
'openbsd' => 'OpenBSD',
|
||||
'gnu' => 'GNU/Linux',
|
||||
'unix' => 'Unknown Unix OS',
|
||||
'symbian' => 'Symbian OS',
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Browsers
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* The order of this array should NOT be changed. Many browsers return
|
||||
* multiple browser types so we want to identify the subtype first.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $browsers = [
|
||||
'OPR' => 'Opera',
|
||||
'Flock' => 'Flock',
|
||||
'Edge' => 'Spartan',
|
||||
'Edg' => 'Edge',
|
||||
'Chrome' => 'Chrome',
|
||||
// Opera 10+ always reports Opera/9.80 and appends Version/<real version> to the user agent string
|
||||
'Opera.*?Version' => 'Opera',
|
||||
'Opera' => 'Opera',
|
||||
'MSIE' => 'Internet Explorer',
|
||||
'Internet Explorer' => 'Internet Explorer',
|
||||
'Trident.* rv' => 'Internet Explorer',
|
||||
'Shiira' => 'Shiira',
|
||||
'Firefox' => 'Firefox',
|
||||
'Chimera' => 'Chimera',
|
||||
'Phoenix' => 'Phoenix',
|
||||
'Firebird' => 'Firebird',
|
||||
'Camino' => 'Camino',
|
||||
'Netscape' => 'Netscape',
|
||||
'OmniWeb' => 'OmniWeb',
|
||||
'Safari' => 'Safari',
|
||||
'Mozilla' => 'Mozilla',
|
||||
'Konqueror' => 'Konqueror',
|
||||
'icab' => 'iCab',
|
||||
'Lynx' => 'Lynx',
|
||||
'Links' => 'Links',
|
||||
'hotjava' => 'HotJava',
|
||||
'amaya' => 'Amaya',
|
||||
'IBrowse' => 'IBrowse',
|
||||
'Maxthon' => 'Maxthon',
|
||||
'Ubuntu' => 'Ubuntu Web Browser',
|
||||
'Vivaldi' => 'Vivaldi',
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Mobiles
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $mobiles = [
|
||||
// legacy array, old values commented out
|
||||
'mobileexplorer' => 'Mobile Explorer',
|
||||
// 'openwave' => 'Open Wave',
|
||||
// 'opera mini' => 'Opera Mini',
|
||||
// 'operamini' => 'Opera Mini',
|
||||
// 'elaine' => 'Palm',
|
||||
'palmsource' => 'Palm',
|
||||
// 'digital paths' => 'Palm',
|
||||
// 'avantgo' => 'Avantgo',
|
||||
// 'xiino' => 'Xiino',
|
||||
'palmscape' => 'Palmscape',
|
||||
// 'nokia' => 'Nokia',
|
||||
// 'ericsson' => 'Ericsson',
|
||||
// 'blackberry' => 'BlackBerry',
|
||||
// 'motorola' => 'Motorola'
|
||||
|
||||
// Phones and Manufacturers
|
||||
'motorola' => 'Motorola',
|
||||
'nokia' => 'Nokia',
|
||||
'palm' => 'Palm',
|
||||
'iphone' => 'Apple iPhone',
|
||||
'ipad' => 'iPad',
|
||||
'ipod' => 'Apple iPod Touch',
|
||||
'sony' => 'Sony Ericsson',
|
||||
'ericsson' => 'Sony Ericsson',
|
||||
'blackberry' => 'BlackBerry',
|
||||
'cocoon' => 'O2 Cocoon',
|
||||
'blazer' => 'Treo',
|
||||
'lg' => 'LG',
|
||||
'amoi' => 'Amoi',
|
||||
'xda' => 'XDA',
|
||||
'mda' => 'MDA',
|
||||
'vario' => 'Vario',
|
||||
'htc' => 'HTC',
|
||||
'samsung' => 'Samsung',
|
||||
'sharp' => 'Sharp',
|
||||
'sie-' => 'Siemens',
|
||||
'alcatel' => 'Alcatel',
|
||||
'benq' => 'BenQ',
|
||||
'ipaq' => 'HP iPaq',
|
||||
'mot-' => 'Motorola',
|
||||
'playstation portable' => 'PlayStation Portable',
|
||||
'playstation 3' => 'PlayStation 3',
|
||||
'playstation vita' => 'PlayStation Vita',
|
||||
'hiptop' => 'Danger Hiptop',
|
||||
'nec-' => 'NEC',
|
||||
'panasonic' => 'Panasonic',
|
||||
'philips' => 'Philips',
|
||||
'sagem' => 'Sagem',
|
||||
'sanyo' => 'Sanyo',
|
||||
'spv' => 'SPV',
|
||||
'zte' => 'ZTE',
|
||||
'sendo' => 'Sendo',
|
||||
'nintendo dsi' => 'Nintendo DSi',
|
||||
'nintendo ds' => 'Nintendo DS',
|
||||
'nintendo 3ds' => 'Nintendo 3DS',
|
||||
'wii' => 'Nintendo Wii',
|
||||
'open web' => 'Open Web',
|
||||
'openweb' => 'OpenWeb',
|
||||
|
||||
// Operating Systems
|
||||
'android' => 'Android',
|
||||
'symbian' => 'Symbian',
|
||||
'SymbianOS' => 'SymbianOS',
|
||||
'elaine' => 'Palm',
|
||||
'series60' => 'Symbian S60',
|
||||
'windows ce' => 'Windows CE',
|
||||
|
||||
// Browsers
|
||||
'obigo' => 'Obigo',
|
||||
'netfront' => 'Netfront Browser',
|
||||
'openwave' => 'Openwave Browser',
|
||||
'mobilexplorer' => 'Mobile Explorer',
|
||||
'operamini' => 'Opera Mini',
|
||||
'opera mini' => 'Opera Mini',
|
||||
'opera mobi' => 'Opera Mobile',
|
||||
'fennec' => 'Firefox Mobile',
|
||||
|
||||
// Other
|
||||
'digital paths' => 'Digital Paths',
|
||||
'avantgo' => 'AvantGo',
|
||||
'xiino' => 'Xiino',
|
||||
'novarra' => 'Novarra Transcoder',
|
||||
'vodafone' => 'Vodafone',
|
||||
'docomo' => 'NTT DoCoMo',
|
||||
'o2' => 'O2',
|
||||
|
||||
// Fallback
|
||||
'mobile' => 'Generic Mobile',
|
||||
'wireless' => 'Generic Mobile',
|
||||
'j2me' => 'Generic Mobile',
|
||||
'midp' => 'Generic Mobile',
|
||||
'cldc' => 'Generic Mobile',
|
||||
'up.link' => 'Generic Mobile',
|
||||
'up.browser' => 'Generic Mobile',
|
||||
'smartphone' => 'Generic Mobile',
|
||||
'cellphone' => 'Generic Mobile',
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------------------------
|
||||
* Robots
|
||||
* -------------------------------------------------------------------
|
||||
*
|
||||
* There are hundred of bots but these are the most common.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $robots = [
|
||||
'googlebot' => 'Googlebot',
|
||||
'msnbot' => 'MSNBot',
|
||||
'baiduspider' => 'Baiduspider',
|
||||
'bingbot' => 'Bing',
|
||||
'slurp' => 'Inktomi Slurp',
|
||||
'yahoo' => 'Yahoo',
|
||||
'ask jeeves' => 'Ask Jeeves',
|
||||
'fastcrawler' => 'FastCrawler',
|
||||
'infoseek' => 'InfoSeek Robot 1.0',
|
||||
'lycos' => 'Lycos',
|
||||
'yandex' => 'YandexBot',
|
||||
'mediapartners-google' => 'MediaPartners Google',
|
||||
'CRAZYWEBCRAWLER' => 'Crazy Webcrawler',
|
||||
'adsbot-google' => 'AdsBot Google',
|
||||
'feedfetcher-google' => 'Feedfetcher Google',
|
||||
'curious george' => 'Curious George',
|
||||
'ia_archiver' => 'Alexa Crawler',
|
||||
'MJ12bot' => 'Majestic-12',
|
||||
'Uptimebot' => 'Uptimebot',
|
||||
];
|
||||
}
|
||||
44
app/Config/Validation.php
Normal file
44
app/Config/Validation.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
use CodeIgniter\Validation\StrictRules\CreditCardRules;
|
||||
use CodeIgniter\Validation\StrictRules\FileRules;
|
||||
use CodeIgniter\Validation\StrictRules\FormatRules;
|
||||
use CodeIgniter\Validation\StrictRules\Rules;
|
||||
|
||||
class Validation extends BaseConfig
|
||||
{
|
||||
// --------------------------------------------------------------------
|
||||
// Setup
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stores the classes that contain the
|
||||
* rules that are available.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $ruleSets = [
|
||||
Rules::class,
|
||||
FormatRules::class,
|
||||
FileRules::class,
|
||||
CreditCardRules::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Specifies the views that are used to display the
|
||||
* errors.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $templates = [
|
||||
'list' => 'CodeIgniter\Validation\Views\list',
|
||||
'single' => 'CodeIgniter\Validation\Views\single',
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Rules
|
||||
// --------------------------------------------------------------------
|
||||
}
|
||||
62
app/Config/View.php
Normal file
62
app/Config/View.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\View as BaseView;
|
||||
use CodeIgniter\View\ViewDecoratorInterface;
|
||||
|
||||
/**
|
||||
* @phpstan-type parser_callable (callable(mixed): mixed)
|
||||
* @phpstan-type parser_callable_string (callable(mixed): mixed)&string
|
||||
*/
|
||||
class View extends BaseView
|
||||
{
|
||||
/**
|
||||
* When false, the view method will clear the data between each
|
||||
* call. This keeps your data safe and ensures there is no accidental
|
||||
* leaking between calls, so you would need to explicitly pass the data
|
||||
* to each view. You might prefer to have the data stick around between
|
||||
* calls so that it is available to all views. If that is the case,
|
||||
* set $saveData to true.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $saveData = true;
|
||||
|
||||
/**
|
||||
* Parser Filters map a filter name with any PHP callable. When the
|
||||
* Parser prepares a variable for display, it will chain it
|
||||
* through the filters in the order defined, inserting any parameters.
|
||||
* To prevent potential abuse, all filters MUST be defined here
|
||||
* in order for them to be available for use within the Parser.
|
||||
*
|
||||
* Examples:
|
||||
* { title|esc(js) }
|
||||
* { created_on|date(Y-m-d)|esc(attr) }
|
||||
*
|
||||
* @var array<string, string>
|
||||
* @phpstan-var array<string, parser_callable_string>
|
||||
*/
|
||||
public $filters = [];
|
||||
|
||||
/**
|
||||
* Parser Plugins provide a way to extend the functionality provided
|
||||
* by the core Parser by creating aliases that will be replaced with
|
||||
* any callable. Can be single or tag pair.
|
||||
*
|
||||
* @var array<string, callable|list<string>|string>
|
||||
* @phpstan-var array<string, list<parser_callable_string>|parser_callable_string|parser_callable>
|
||||
*/
|
||||
public $plugins = [];
|
||||
|
||||
/**
|
||||
* View Decorators are class methods that will be run in sequence to
|
||||
* have a chance to alter the generated output just prior to caching
|
||||
* the results.
|
||||
*
|
||||
* All classes must implement CodeIgniter\View\ViewDecoratorInterface
|
||||
*
|
||||
* @var list<class-string<ViewDecoratorInterface>>
|
||||
*/
|
||||
public array $decorators = [];
|
||||
}
|
||||
62
app/Controllers/Admin/SettingsController.php
Normal file
62
app/Controllers/Admin/SettingsController.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\SettingsModel;
|
||||
|
||||
class SettingsController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$settings = new SettingsModel();
|
||||
|
||||
return view('admin/settings', [
|
||||
'title' => 'Application settings | Chart-Board',
|
||||
'allow_registration' => $settings->getBoolean(null, 'allow_registration', true),
|
||||
'max_workspaces' => $settings->getInt(null, 'max_workspaces', 10),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$rules = [
|
||||
'allow_registration' => 'required|in_list[0,1]',
|
||||
'max_workspaces' => 'required|integer|greater_than_equal_to[1]|less_than_equal_to[9999]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$settings = new SettingsModel();
|
||||
$settings->setValue(
|
||||
null,
|
||||
'allow_registration',
|
||||
(string) $this->request->getPost('allow_registration'),
|
||||
'boolean'
|
||||
);
|
||||
$settings->setValue(
|
||||
null,
|
||||
'max_workspaces',
|
||||
(string) $this->request->getPost('max_workspaces'),
|
||||
'integer'
|
||||
);
|
||||
|
||||
AuditLogger::log(
|
||||
'app.settings_updated',
|
||||
'settings',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'allow_registration' => (int) $this->request->getPost('allow_registration'),
|
||||
'max_workspaces' => (int) $this->request->getPost('max_workspaces'),
|
||||
],
|
||||
null,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/admin/settings')->with('success', 'Application settings updated.');
|
||||
}
|
||||
}
|
||||
95
app/Controllers/Admin/UserController.php
Normal file
95
app/Controllers/Admin/UserController.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
$search = trim((string) $this->request->getGet('search'));
|
||||
$status = (string) $this->request->getGet('status');
|
||||
|
||||
$builder = $userModel->orderBy('id', 'DESC');
|
||||
if ($search !== '') {
|
||||
$builder = $builder->groupStart()
|
||||
->like('name', $search)
|
||||
->orLike('email', $search)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($status !== '' && in_array($status, ['0', '1'], true)) {
|
||||
$builder = $builder->where('is_active', (int) $status);
|
||||
}
|
||||
|
||||
return view('admin/users/index', [
|
||||
'title' => 'Manage Users | Chart-Board',
|
||||
'users' => $builder->paginate(10),
|
||||
'pager' => $userModel->pager,
|
||||
'search' => $search,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($id);
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->to('/admin/users')->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
return view('admin/users/edit', [
|
||||
'title' => 'Edit User | Chart-Board',
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
$rules = [
|
||||
'role' => 'required|in_list[superadmin,user]',
|
||||
'is_active' => 'required|in_list[0,1]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($id);
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->to('/admin/users')->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
$oldSnap = [
|
||||
'role' => $user['role'] ?? null,
|
||||
'is_active' => $user['is_active'] ?? null,
|
||||
];
|
||||
$userModel->update($id, [
|
||||
'role' => (string) $this->request->getPost('role'),
|
||||
'is_active' => (int) $this->request->getPost('is_active'),
|
||||
]);
|
||||
|
||||
AuditLogger::log(
|
||||
'user.admin_role_updated',
|
||||
'user',
|
||||
$id,
|
||||
$oldSnap,
|
||||
[
|
||||
'role' => (string) $this->request->getPost('role'),
|
||||
'is_active' => (int) $this->request->getPost('is_active'),
|
||||
],
|
||||
null,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/admin/users')->with('success', 'User updated.');
|
||||
}
|
||||
}
|
||||
232
app/Controllers/Alert/AlertController.php
Normal file
232
app/Controllers/Alert/AlertController.php
Normal file
@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Alert;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AlertHistoryModel;
|
||||
use App\Models\AlertModel;
|
||||
use App\Models\ChartModel;
|
||||
|
||||
class AlertController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$alerts = (new AlertModel())->forWorkspace($workspaceId);
|
||||
$recent = (new AlertHistoryModel())->recentForWorkspace($workspaceId, 12);
|
||||
|
||||
return view('alert/index', [
|
||||
'title' => 'Alerts | Chart-Board',
|
||||
'alerts' => $alerts,
|
||||
'recent' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$charts = (new ChartModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('alert/create', [
|
||||
'title' => 'New alert | Chart-Board',
|
||||
'charts' => $charts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'chart_id' => 'required|integer',
|
||||
'metric_field' => 'required|max_length[150]',
|
||||
'condition' => 'required|in_list[gt,lt,eq,gte,lte]',
|
||||
'threshold' => 'required|decimal',
|
||||
'check_interval' => 'permit_empty|integer',
|
||||
'notify_email' => 'permit_empty|in_list[0,1]',
|
||||
'notify_slack' => 'permit_empty|in_list[0,1]',
|
||||
'email_addresses' => 'permit_empty|max_length[2000]',
|
||||
'slack_webhook' => 'permit_empty|max_length[500]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$chartId = (int) $this->request->getPost('chart_id');
|
||||
$chart = (new ChartModel())->where('workspace_id', $workspaceId)->find($chartId);
|
||||
if (! $chart) {
|
||||
return redirect()->back()->withInput()->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
$notifyEmail = (int) $this->request->getPost('notify_email') === 1 ? 1 : 0;
|
||||
$notifySlack = (int) $this->request->getPost('notify_slack') === 1 ? 1 : 0;
|
||||
|
||||
(new AlertModel())->insert([
|
||||
'workspace_id' => $workspaceId,
|
||||
'chart_id' => $chartId,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'metric_field' => trim((string) $this->request->getPost('metric_field')),
|
||||
'condition' => (string) $this->request->getPost('condition'),
|
||||
'threshold' => (float) $this->request->getPost('threshold'),
|
||||
'check_interval' => max(1, (int) ($this->request->getPost('check_interval') ?: 15)),
|
||||
'notify_email' => $notifyEmail,
|
||||
'email_addresses' => trim((string) $this->request->getPost('email_addresses')) ?: null,
|
||||
'notify_slack' => $notifySlack,
|
||||
'slack_webhook' => trim((string) $this->request->getPost('slack_webhook')) ?: null,
|
||||
'is_active' => 1,
|
||||
'is_muted_until' => null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return redirect()->to('/alert')->with('success', 'Alert created.');
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$charts = (new ChartModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('alert/edit', [
|
||||
'title' => 'Edit alert | Chart-Board',
|
||||
'alert' => $alert,
|
||||
'charts' => $charts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'chart_id' => 'required|integer',
|
||||
'metric_field' => 'required|max_length[150]',
|
||||
'condition' => 'required|in_list[gt,lt,eq,gte,lte]',
|
||||
'threshold' => 'required|decimal',
|
||||
'check_interval' => 'permit_empty|integer',
|
||||
'notify_email' => 'permit_empty|in_list[0,1]',
|
||||
'notify_slack' => 'permit_empty|in_list[0,1]',
|
||||
'email_addresses' => 'permit_empty|max_length[2000]',
|
||||
'slack_webhook' => 'permit_empty|max_length[500]',
|
||||
'is_active' => 'permit_empty|in_list[0,1]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$chartId = (int) $this->request->getPost('chart_id');
|
||||
$chart = (new ChartModel())->where('workspace_id', $workspaceId)->find($chartId);
|
||||
if (! $chart) {
|
||||
return redirect()->back()->withInput()->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
$notifyEmail = (int) $this->request->getPost('notify_email') === 1 ? 1 : 0;
|
||||
$notifySlack = (int) $this->request->getPost('notify_slack') === 1 ? 1 : 0;
|
||||
$isActive = (int) $this->request->getPost('is_active') === 1 ? 1 : 0;
|
||||
|
||||
$model->update($id, [
|
||||
'chart_id' => $chartId,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'metric_field' => trim((string) $this->request->getPost('metric_field')),
|
||||
'condition' => (string) $this->request->getPost('condition'),
|
||||
'threshold' => (float) $this->request->getPost('threshold'),
|
||||
'check_interval' => max(1, (int) ($this->request->getPost('check_interval') ?: 15)),
|
||||
'notify_email' => $notifyEmail,
|
||||
'email_addresses' => trim((string) $this->request->getPost('email_addresses')) ?: null,
|
||||
'notify_slack' => $notifySlack,
|
||||
'slack_webhook' => trim((string) $this->request->getPost('slack_webhook')) ?: null,
|
||||
'is_active' => $isActive,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Alert updated.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$model->delete($id);
|
||||
|
||||
return redirect()->to('/alert')->with('success', 'Alert deleted.');
|
||||
}
|
||||
|
||||
public function mute(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$hours = (int) $this->request->getPost('hours');
|
||||
if (! in_array($hours, [1, 4, 24], true)) {
|
||||
$hours = 1;
|
||||
}
|
||||
|
||||
$until = date('Y-m-d H:i:s', time() + $hours * 3600);
|
||||
$model->update($id, ['is_muted_until' => $until]);
|
||||
|
||||
return redirect()->back()->with('success', 'Alert muted for ' . $hours . ' hour(s).');
|
||||
}
|
||||
|
||||
public function unmute(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$model->update($id, ['is_muted_until' => null]);
|
||||
|
||||
return redirect()->back()->with('success', 'Mute cleared.');
|
||||
}
|
||||
|
||||
public function history(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new AlertModel();
|
||||
$alert = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $alert) {
|
||||
return redirect()->to('/alert')->with('error', 'Alert not found.');
|
||||
}
|
||||
|
||||
$page = max(1, (int) $this->request->getGet('page'));
|
||||
$perPage = 25;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$histModel = new AlertHistoryModel();
|
||||
$rows = $histModel->forAlert($id, $perPage, $offset);
|
||||
$total = $histModel->countForAlert($id);
|
||||
|
||||
return view('alert/history', [
|
||||
'title' => 'Alert history | Chart-Board',
|
||||
'alert' => $alert,
|
||||
'history' => $rows,
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
135
app/Controllers/Audit/AuditController.php
Normal file
135
app/Controllers/Audit/AuditController.php
Normal file
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Audit;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\AuditLogModel;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
|
||||
class AuditController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$session = $this->session;
|
||||
$isSuper = (string) $session->get('role') === 'superadmin';
|
||||
$activeWs = (int) $session->get('active_workspace_id');
|
||||
|
||||
$scope = (string) $this->request->getGet('scope');
|
||||
if (! in_array($scope, ['active', 'global', 'all'], true)) {
|
||||
$scope = 'active';
|
||||
}
|
||||
|
||||
if (! $isSuper && $scope !== 'active') {
|
||||
$scope = 'active';
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'user_id' => (int) $this->request->getGet('user_id'),
|
||||
'action' => trim((string) $this->request->getGet('action')),
|
||||
'date_from' => trim((string) $this->request->getGet('date_from')),
|
||||
'date_to' => trim((string) $this->request->getGet('date_to')),
|
||||
];
|
||||
|
||||
if ($scope === 'global') {
|
||||
$filters['workspace_id'] = null;
|
||||
} elseif ($scope === 'all' && $isSuper) {
|
||||
unset($filters['workspace_id']);
|
||||
} else {
|
||||
$filters['workspace_id'] = $activeWs > 0 ? $activeWs : null;
|
||||
}
|
||||
|
||||
$model = new AuditLogModel();
|
||||
$page = $model->paginateFiltered(25, $filters);
|
||||
|
||||
$memberUsers = [];
|
||||
if ($activeWs > 0) {
|
||||
$rows = (new WorkspaceMemberModel())
|
||||
->select('workspace_members.user_id, users.name, users.email')
|
||||
->join('users', 'users.id = workspace_members.user_id')
|
||||
->where('workspace_members.workspace_id', $activeWs)
|
||||
->orderBy('users.name', 'ASC')
|
||||
->findAll();
|
||||
foreach ($rows as $r) {
|
||||
$memberUsers[] = [
|
||||
'id' => (int) $r['user_id'],
|
||||
'name' => (string) ($r['name'] ?? ''),
|
||||
'email' => (string) ($r['email'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('audit/index', [
|
||||
'title' => 'Audit log | Chart-Board',
|
||||
'logs' => $page['data'],
|
||||
'pager' => $page['pager'],
|
||||
'filters' => $filters,
|
||||
'scope' => $scope,
|
||||
'isSuperadmin' => $isSuper,
|
||||
'memberUsers' => $memberUsers,
|
||||
'activeWorkspace' => $activeWs,
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(int $id)
|
||||
{
|
||||
$row = (new AuditLogModel())->findWithUser($id);
|
||||
if (! $row) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$session = $this->session;
|
||||
$isSuper = (string) $session->get('role') === 'superadmin';
|
||||
$activeWs = (int) $session->get('active_workspace_id');
|
||||
$wid = $row['workspace_id'] !== null ? (int) $row['workspace_id'] : null;
|
||||
|
||||
if (! $isSuper) {
|
||||
if ($wid === null || $wid !== $activeWs) {
|
||||
if ($wid !== null) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Forbidden.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Forbidden.'])->setStatusCode(403);
|
||||
}
|
||||
$member = (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $activeWs)
|
||||
->where('user_id', (int) $session->get('user_id'))
|
||||
->first();
|
||||
if (! $member || (string) ($member['role'] ?? '') !== 'admin') {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Forbidden.'])->setStatusCode(403);
|
||||
}
|
||||
}
|
||||
|
||||
$old = $this->decodeJson($row['old_value'] ?? null);
|
||||
$new = $this->decodeJson($row['new_value'] ?? null);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'log' => [
|
||||
'id' => (int) $row['id'],
|
||||
'action' => (string) ($row['action'] ?? ''),
|
||||
'resource_type' => $row['resource_type'],
|
||||
'resource_id' => $row['resource_id'],
|
||||
'user_name' => $row['user_name'] ?? null,
|
||||
'user_email' => $row['user_email'] ?? null,
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'ip_address' => $row['ip_address'] ?? null,
|
||||
'user_agent' => $row['user_agent'] ?? null,
|
||||
'old_value_json' => json_encode($old, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'new_value_json' => json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $raw): mixed
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
$d = json_decode((string) $raw, true);
|
||||
|
||||
return is_array($d) ? $d : (string) $raw;
|
||||
}
|
||||
}
|
||||
117
app/Controllers/Auth/LoginController.php
Normal file
117
app/Controllers/Auth/LoginController.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Auth;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\WorkspaceModel;
|
||||
|
||||
class LoginController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if ($this->session->get('user_id')) {
|
||||
return redirect()->to('/dashboard');
|
||||
}
|
||||
|
||||
return view('auth/login', ['title' => 'Login | Chart-Board']);
|
||||
}
|
||||
|
||||
public function authenticate()
|
||||
{
|
||||
$rules = [
|
||||
'email' => 'required|valid_email|max_length[255]',
|
||||
'password' => 'required|min_length[8]|max_length[255]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$email = strtolower((string) $this->request->getPost('email'));
|
||||
$password = (string) $this->request->getPost('password');
|
||||
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->findByEmail($email);
|
||||
|
||||
if (! $user || ! password_verify($password, (string) $user['password'])) {
|
||||
return redirect()->back()->withInput()->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if ((int) $user['is_active'] !== 1) {
|
||||
return redirect()->back()->withInput()->with('error', 'Your account is inactive.');
|
||||
}
|
||||
|
||||
if ((int) $user['email_verified'] !== 1) {
|
||||
return redirect()->back()->withInput()->with('error', 'Please verify your email first.');
|
||||
}
|
||||
|
||||
$sessionData = [
|
||||
'user_id' => (int) $user['id'],
|
||||
'name' => (string) $user['name'],
|
||||
'email' => (string) $user['email'],
|
||||
'role' => (string) $user['role'],
|
||||
];
|
||||
|
||||
$this->session->set($sessionData);
|
||||
$this->session->regenerate(true);
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+4 hours'));
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('user_sessions')->insert([
|
||||
'user_id' => (int) $user['id'],
|
||||
'session_token' => $token,
|
||||
'ip_address' => $this->request->getIPAddress(),
|
||||
'user_agent' => substr((string) $this->request->getUserAgent(), 0, 500),
|
||||
'last_active' => date('Y-m-d H:i:s'),
|
||||
'expires_at' => $expiresAt,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$userModel->update((int) $user['id'], ['last_login_at' => date('Y-m-d H:i:s')]);
|
||||
$this->session->set('session_token', $token);
|
||||
$workspaces = (new WorkspaceModel())->forUser((int) $user['id']);
|
||||
if ($workspaces !== []) {
|
||||
$this->session->set('active_workspace_id', (int) $workspaces[0]['id']);
|
||||
}
|
||||
|
||||
$theme = (string) ($user['theme_preference'] ?? 'light');
|
||||
if (! in_array($theme, ['light', 'dark', 'system'], true)) {
|
||||
$theme = 'light';
|
||||
}
|
||||
$this->session->set('theme_preference', $theme);
|
||||
|
||||
AuditLogger::log(
|
||||
'user.login',
|
||||
'user',
|
||||
(int) $user['id'],
|
||||
null,
|
||||
['email' => (string) $user['email']],
|
||||
(int) $this->session->get('active_workspace_id') ?: null,
|
||||
(int) $user['id']
|
||||
);
|
||||
|
||||
return redirect()->to('/dashboard')->with('success', 'Welcome back, ' . esc((string) $user['name']) . '.');
|
||||
}
|
||||
|
||||
public function logout()
|
||||
{
|
||||
$uid = (int) $this->session->get('user_id');
|
||||
$wid = (int) $this->session->get('active_workspace_id');
|
||||
if ($uid > 0) {
|
||||
AuditLogger::log('user.logout', 'user', $uid, null, null, $wid > 0 ? $wid : null, $uid);
|
||||
}
|
||||
|
||||
$token = $this->session->get('session_token');
|
||||
if ($token) {
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('user_sessions')->where('session_token', $token)->delete();
|
||||
}
|
||||
|
||||
$this->session->destroy();
|
||||
return redirect()->to('/login')->with('success', 'You have been logged out.');
|
||||
}
|
||||
}
|
||||
98
app/Controllers/Auth/PasswordController.php
Normal file
98
app/Controllers/Auth/PasswordController.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Auth;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class PasswordController extends BaseController
|
||||
{
|
||||
public function forgot()
|
||||
{
|
||||
return view('auth/forgot', ['title' => 'Forgot Password | Chart-Board']);
|
||||
}
|
||||
|
||||
public function sendResetLink()
|
||||
{
|
||||
$rules = [
|
||||
'email' => 'required|valid_email|max_length[255]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$emailAddress = strtolower((string) $this->request->getPost('email'));
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->findByEmail($emailAddress);
|
||||
|
||||
if ($user) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = date('Y-m-d H:i:s', strtotime('+30 minutes'));
|
||||
|
||||
$userModel->update((int) $user['id'], [
|
||||
'reset_token' => $token,
|
||||
'reset_token_expiry' => $expiry,
|
||||
]);
|
||||
|
||||
$resetLink = base_url('reset-password/' . $token);
|
||||
$email = service('email');
|
||||
$email->setTo($emailAddress);
|
||||
$email->setSubject('Reset your Chart-Board password');
|
||||
$email->setMessage('Click to reset your password: <a href="' . esc($resetLink, 'attr') . '">' . esc($resetLink) . '</a>');
|
||||
$email->send();
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'If the email exists, a reset link has been sent.');
|
||||
}
|
||||
|
||||
public function reset(string $token)
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->where('reset_token', $token)->first();
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Invalid reset link.');
|
||||
}
|
||||
|
||||
if (! empty($user['reset_token_expiry']) && strtotime((string) $user['reset_token_expiry']) < time()) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Reset link has expired.');
|
||||
}
|
||||
|
||||
return view('auth/reset', [
|
||||
'title' => 'Reset Password | Chart-Board',
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePassword(string $token)
|
||||
{
|
||||
$rules = [
|
||||
'password' => 'required|min_length[8]|max_length[255]',
|
||||
'confirm_password' => 'required|matches[password]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->where('reset_token', $token)->first();
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Invalid reset token.');
|
||||
}
|
||||
|
||||
if (! empty($user['reset_token_expiry']) && strtotime((string) $user['reset_token_expiry']) < time()) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Reset token expired.');
|
||||
}
|
||||
|
||||
$userModel->update((int) $user['id'], [
|
||||
'password' => (string) $this->request->getPost('password'),
|
||||
'reset_token' => null,
|
||||
'reset_token_expiry' => null,
|
||||
]);
|
||||
|
||||
return redirect()->to('/login')->with('success', 'Password reset successful. Please login.');
|
||||
}
|
||||
}
|
||||
80
app/Controllers/Auth/RegisterController.php
Normal file
80
app/Controllers/Auth/RegisterController.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Auth;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\SettingsModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class RegisterController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if ($this->session->get('user_id')) {
|
||||
return redirect()->to('/dashboard');
|
||||
}
|
||||
|
||||
if (! (new SettingsModel())->getBoolean(null, 'allow_registration', true)) {
|
||||
return redirect()->to('/login')->with('error', 'New registrations are currently disabled.');
|
||||
}
|
||||
|
||||
return view('auth/register', ['title' => 'Register | Chart-Board']);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
if (! (new SettingsModel())->getBoolean(null, 'allow_registration', true)) {
|
||||
return redirect()->to('/login')->with('error', 'New registrations are currently disabled.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'email' => 'required|valid_email|max_length[255]|is_unique[users.email]',
|
||||
'password' => 'required|min_length[8]|max_length[255]',
|
||||
'confirm_password' => 'required|matches[password]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$userModel = new UserModel();
|
||||
|
||||
$userModel->insert([
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'email' => strtolower((string) $this->request->getPost('email')),
|
||||
'password' => (string) $this->request->getPost('password'),
|
||||
'verify_token' => $token,
|
||||
'email_verified' => 0,
|
||||
'role' => 'user',
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
$verifyLink = base_url('verify/' . $token);
|
||||
$email = service('email');
|
||||
$email->setTo((string) $this->request->getPost('email'));
|
||||
$email->setSubject('Verify your Chart-Board account');
|
||||
$email->setMessage('Click to verify your account: <a href="' . esc($verifyLink, 'attr') . '">' . esc($verifyLink) . '</a>');
|
||||
$email->send();
|
||||
|
||||
return redirect()->to('/login')->with('success', 'Registration successful. Please verify your email.');
|
||||
}
|
||||
|
||||
public function verify(string $token)
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->where('verify_token', $token)->first();
|
||||
|
||||
if (! $user) {
|
||||
return redirect()->to('/login')->with('error', 'Invalid verification link.');
|
||||
}
|
||||
|
||||
$userModel->update((int) $user['id'], [
|
||||
'email_verified' => 1,
|
||||
'verify_token' => null,
|
||||
]);
|
||||
|
||||
return redirect()->to('/login')->with('success', 'Email verified. You can now login.');
|
||||
}
|
||||
}
|
||||
96
app/Controllers/BaseController.php
Normal file
96
app/Controllers/BaseController.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\AlertHistoryModel;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
use App\Models\WorkspaceModel;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* BaseController provides a convenient place for loading components
|
||||
* and performing functions that are needed by all your controllers.
|
||||
*
|
||||
* Extend this class in any new controllers:
|
||||
* ```
|
||||
* class Home extends BaseController
|
||||
* ```
|
||||
*
|
||||
* For security, be sure to declare any new methods as protected or private.
|
||||
*/
|
||||
abstract class BaseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Be sure to declare properties for any property fetch you initialized.
|
||||
* The creation of dynamic property is deprecated in PHP 8.2.
|
||||
*/
|
||||
|
||||
protected $session;
|
||||
protected array $sharedData = [];
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
||||
{
|
||||
// Load here all helpers you want to be available in your controllers that extend BaseController.
|
||||
// Caution: Do not put the this below the parent::initController() call below.
|
||||
$this->helpers = ['form', 'url'];
|
||||
|
||||
// Caution: Do not edit this line.
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
// Preload any models, libraries, etc, here.
|
||||
$this->session = service('session');
|
||||
$workspaceList = [];
|
||||
$activeWorkspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
if ($userId > 0) {
|
||||
$workspaceList = (new WorkspaceModel())->forUser($userId);
|
||||
if ($activeWorkspaceId <= 0 && $workspaceList !== []) {
|
||||
$activeWorkspaceId = (int) $workspaceList[0]['id'];
|
||||
$this->session->set('active_workspace_id', $activeWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
$alertBadgeCount = 0;
|
||||
if ($userId > 0 && $activeWorkspaceId > 0) {
|
||||
$alertBadgeCount = (new AlertHistoryModel())->countRecentTriggeredAlerts($activeWorkspaceId, 3600);
|
||||
}
|
||||
|
||||
$themePref = (string) ($this->session->get('theme_preference') ?? 'light');
|
||||
if (! in_array($themePref, ['light', 'dark', 'system'], true)) {
|
||||
$themePref = 'light';
|
||||
}
|
||||
|
||||
$canWorkspaceAdmin = false;
|
||||
if ((string) $this->session->get('role') === 'superadmin') {
|
||||
$canWorkspaceAdmin = true;
|
||||
} elseif ($userId > 0 && $activeWorkspaceId > 0) {
|
||||
$mem = (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $activeWorkspaceId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
$canWorkspaceAdmin = $mem !== null && (string) ($mem['role'] ?? '') === 'admin';
|
||||
}
|
||||
|
||||
$this->sharedData = [
|
||||
'currentUser' => [
|
||||
'id' => $this->session->get('user_id'),
|
||||
'name' => $this->session->get('name'),
|
||||
'email' => $this->session->get('email'),
|
||||
'role' => $this->session->get('role'),
|
||||
],
|
||||
'activeWorkspaceId' => $activeWorkspaceId,
|
||||
'workspaceList' => $workspaceList,
|
||||
'alertBadgeCount' => $alertBadgeCount,
|
||||
'themePreference' => $themePref,
|
||||
'canWorkspaceAdmin' => $canWorkspaceAdmin,
|
||||
];
|
||||
|
||||
service('renderer')->setData($this->sharedData);
|
||||
}
|
||||
}
|
||||
689
app/Controllers/Chart/ChartController.php
Normal file
689
app/Controllers/Chart/ChartController.php
Normal file
@ -0,0 +1,689 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Chart;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Libraries\ChartRenderer;
|
||||
use App\Libraries\SavedQueryRunner;
|
||||
use App\Models\ChartExportModel;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DataSourceModel;
|
||||
use App\Models\QueryVariableModel;
|
||||
use App\Models\SavedQueryModel;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Throwable;
|
||||
|
||||
class ChartController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$charts = (new ChartModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('chart/index', [
|
||||
'title' => 'Charts | Chart-Board',
|
||||
'charts' => $charts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$queries = (new SavedQueryModel())->forWorkspace($workspaceId);
|
||||
$preselectQueryId = (int) ($this->request->getGet('query') ?? 0);
|
||||
|
||||
return view('chart/builder', [
|
||||
'title' => 'Create Chart | Chart-Board',
|
||||
'mode' => 'create',
|
||||
'chart' => null,
|
||||
'queries' => $queries,
|
||||
'preselectQueryId' => $preselectQueryId,
|
||||
'displayConfig' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $chart) {
|
||||
return redirect()->to('/chart')->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
$queries = (new SavedQueryModel())->forWorkspace($workspaceId);
|
||||
$displayConfig = $this->decodeJson($chart['display_config'] ?? null);
|
||||
|
||||
return view('chart/builder', [
|
||||
'title' => 'Edit Chart | Chart-Board',
|
||||
'mode' => 'edit',
|
||||
'chart' => $chart,
|
||||
'queries' => $queries,
|
||||
'preselectQueryId' => (int) ($chart['saved_query_id'] ?? 0),
|
||||
'displayConfig' => $displayConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'saved_query_id' => 'required|integer',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
|
||||
'x_field' => 'permit_empty|max_length[150]',
|
||||
'y_field' => 'permit_empty|max_length[150]',
|
||||
'group_field' => 'permit_empty|max_length[150]',
|
||||
'value_field' => 'permit_empty|max_length[150]',
|
||||
'display_config' => 'permit_empty',
|
||||
'refresh_interval'=> 'permit_empty|integer',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$savedQueryId = (int) $this->request->getPost('saved_query_id');
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
|
||||
if (! $savedQuery) {
|
||||
return redirect()->back()->withInput()->with('error', 'Selected query was not found.');
|
||||
}
|
||||
|
||||
$displayConfig = $this->sanitizeDisplayConfig($this->request->getPost('display_config'));
|
||||
|
||||
$payload = [
|
||||
'workspace_id' => $workspaceId,
|
||||
'data_source_id' => (int) $savedQuery['data_source_id'],
|
||||
'saved_query_id' => $savedQueryId,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'description' => trim((string) $this->request->getPost('description')) ?: null,
|
||||
'chart_type' => (string) $this->request->getPost('chart_type'),
|
||||
'query_type' => (string) $savedQuery['query_type'],
|
||||
'raw_sql' => null,
|
||||
'visual_config' => null,
|
||||
'api_endpoint' => null,
|
||||
'api_params' => null,
|
||||
'response_path' => null,
|
||||
'field_map' => null,
|
||||
'x_field' => trim((string) $this->request->getPost('x_field')) ?: null,
|
||||
'y_field' => trim((string) $this->request->getPost('y_field')) ?: null,
|
||||
'group_field' => trim((string) $this->request->getPost('group_field')) ?: null,
|
||||
'value_field' => trim((string) $this->request->getPost('value_field')) ?: null,
|
||||
'display_config' => $displayConfig !== [] ? json_encode($displayConfig, JSON_UNESCAPED_UNICODE) : null,
|
||||
'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')),
|
||||
'cache_ttl' => max(0, (int) $savedQuery['cache_ttl']),
|
||||
'is_public' => 0,
|
||||
'public_token' => null,
|
||||
'created_by' => $userId,
|
||||
];
|
||||
|
||||
$chartModel = new ChartModel();
|
||||
$newId = (int) $chartModel->insert($payload, true);
|
||||
|
||||
AuditLogger::log(
|
||||
'chart.created',
|
||||
'chart',
|
||||
$newId,
|
||||
null,
|
||||
[
|
||||
'name' => $payload['name'],
|
||||
'chart_type' => $payload['chart_type'],
|
||||
],
|
||||
$workspaceId,
|
||||
$userId
|
||||
);
|
||||
|
||||
return redirect()->to('/chart/edit/' . $newId)->with('success', 'Chart saved.');
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$chartModel = new ChartModel();
|
||||
$existing = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $existing) {
|
||||
return redirect()->to('/chart')->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'saved_query_id' => 'required|integer',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
|
||||
'x_field' => 'permit_empty|max_length[150]',
|
||||
'y_field' => 'permit_empty|max_length[150]',
|
||||
'group_field' => 'permit_empty|max_length[150]',
|
||||
'value_field' => 'permit_empty|max_length[150]',
|
||||
'display_config' => 'permit_empty',
|
||||
'refresh_interval'=> 'permit_empty|integer',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$savedQueryId = (int) $this->request->getPost('saved_query_id');
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
|
||||
if (! $savedQuery) {
|
||||
return redirect()->back()->withInput()->with('error', 'Selected query was not found.');
|
||||
}
|
||||
|
||||
$displayConfig = $this->sanitizeDisplayConfig($this->request->getPost('display_config'));
|
||||
|
||||
$payload = [
|
||||
'data_source_id' => (int) $savedQuery['data_source_id'],
|
||||
'saved_query_id' => $savedQueryId,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'description' => trim((string) $this->request->getPost('description')) ?: null,
|
||||
'chart_type' => (string) $this->request->getPost('chart_type'),
|
||||
'query_type' => (string) $savedQuery['query_type'],
|
||||
'x_field' => trim((string) $this->request->getPost('x_field')) ?: null,
|
||||
'y_field' => trim((string) $this->request->getPost('y_field')) ?: null,
|
||||
'group_field' => trim((string) $this->request->getPost('group_field')) ?: null,
|
||||
'value_field' => trim((string) $this->request->getPost('value_field')) ?: null,
|
||||
'display_config' => $displayConfig !== [] ? json_encode($displayConfig, JSON_UNESCAPED_UNICODE) : null,
|
||||
'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')),
|
||||
'cache_ttl' => max(0, (int) $savedQuery['cache_ttl']),
|
||||
];
|
||||
|
||||
$oldSnap = [
|
||||
'name' => $existing['name'] ?? null,
|
||||
'chart_type' => $existing['chart_type'] ?? null,
|
||||
'saved_query_id' => $existing['saved_query_id'] ?? null,
|
||||
];
|
||||
$chartModel->update($id, $payload);
|
||||
|
||||
AuditLogger::log(
|
||||
'chart.updated',
|
||||
'chart',
|
||||
$id,
|
||||
$oldSnap,
|
||||
[
|
||||
'name' => $payload['name'],
|
||||
'chart_type' => $payload['chart_type'],
|
||||
'saved_query_id' => $payload['saved_query_id'],
|
||||
],
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Chart updated.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $chart) {
|
||||
return redirect()->to('/chart')->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'chart.deleted',
|
||||
'chart',
|
||||
$id,
|
||||
[
|
||||
'name' => $chart['name'] ?? null,
|
||||
'chart_type' => $chart['chart_type'] ?? null,
|
||||
],
|
||||
null,
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$chartModel->delete($id);
|
||||
|
||||
return redirect()->to('/chart')->with('success', 'Chart deleted.');
|
||||
}
|
||||
|
||||
public function duplicate(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $chart) {
|
||||
return redirect()->to('/chart')->with('error', 'Chart not found.');
|
||||
}
|
||||
|
||||
$newId = $chartModel->duplicateRow($chart, $userId);
|
||||
if ($newId === false) {
|
||||
return redirect()->to('/chart')->with('error', 'Could not duplicate chart.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'chart.duplicated',
|
||||
'chart',
|
||||
(int) $newId,
|
||||
['source_chart_id' => $id],
|
||||
['name' => $chart['name'] ?? null],
|
||||
$workspaceId,
|
||||
$userId
|
||||
);
|
||||
|
||||
return redirect()->to('/chart/edit/' . $newId)->with('success', 'Chart duplicated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved query variable definitions for the chart builder (JSON).
|
||||
*/
|
||||
public function savedQueryVariables(int $savedQueryId)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Query not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$rows = (new QueryVariableModel())
|
||||
->where('saved_query_id', $savedQueryId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$vars = [];
|
||||
foreach ($rows as $row) {
|
||||
$optsRaw = $row['options_json'] ?? null;
|
||||
$options = [];
|
||||
if ($optsRaw !== null && $optsRaw !== '') {
|
||||
$decoded = json_decode((string) $optsRaw, true);
|
||||
$options = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$vars[] = [
|
||||
'name' => (string) $row['name'],
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
'default_value' => (string) ($row['default_value'] ?? ''),
|
||||
'is_required' => ! empty($row['is_required']),
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'variables' => $vars,
|
||||
'query' => [
|
||||
'id' => (int) $savedQuery['id'],
|
||||
'name' => (string) $savedQuery['name'],
|
||||
'query_type' => (string) $savedQuery['query_type'],
|
||||
'data_source_id' => (int) $savedQuery['data_source_id'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a saved query and return preview columns/rows (for builder).
|
||||
*/
|
||||
public function previewQuery()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$savedQueryId = (int) $this->request->getPost('saved_query_id');
|
||||
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Query not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Data source not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$variableValues = json_decode((string) $this->request->getPost('variables_json'), true);
|
||||
if (! is_array($variableValues)) {
|
||||
$variableValues = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, $variableValues);
|
||||
$rows = $result['rows'];
|
||||
$columns = [];
|
||||
if ($rows !== []) {
|
||||
$columns = array_keys($rows[0]);
|
||||
}
|
||||
$previewRows = array_slice($rows, 0, 100);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'columns' => $columns,
|
||||
'rows' => $previewRows,
|
||||
'row_count' => count($rows),
|
||||
'execution_ms' => $result['execution_ms'],
|
||||
'cache_hit' => $result['cache_hit'],
|
||||
'truncated' => count($rows) > 100,
|
||||
],
|
||||
'csrf' => [
|
||||
'name' => csrf_token(),
|
||||
'hash' => csrf_hash(),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run query + build render payload from unsaved builder state (live preview).
|
||||
*/
|
||||
public function previewRender()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$savedQueryId = (int) $this->request->getPost('saved_query_id');
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Query not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Data source not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$variableValues = json_decode((string) $this->request->getPost('variables_json'), true);
|
||||
if (! is_array($variableValues)) {
|
||||
$variableValues = [];
|
||||
}
|
||||
|
||||
$chart = [
|
||||
'chart_type' => (string) $this->request->getPost('chart_type'),
|
||||
'x_field' => trim((string) $this->request->getPost('x_field')) ?: null,
|
||||
'y_field' => trim((string) $this->request->getPost('y_field')) ?: null,
|
||||
'group_field' => trim((string) $this->request->getPost('group_field')) ?: null,
|
||||
'value_field' => trim((string) $this->request->getPost('value_field')) ?: null,
|
||||
'display_config' => $this->request->getPost('display_config'),
|
||||
];
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, $variableValues);
|
||||
$rows = $result['rows'];
|
||||
$renderer = new ChartRenderer();
|
||||
$payload = $renderer->buildPayload($chart, $rows);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'row_count' => count($rows),
|
||||
'execution_ms' => $result['execution_ms'],
|
||||
'cache_hit' => $result['cache_hit'],
|
||||
'chart_type' => $chart['chart_type'],
|
||||
],
|
||||
'payload' => $payload,
|
||||
'csrf' => [
|
||||
'name' => csrf_token(),
|
||||
'hash' => csrf_hash(),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh data + render payload for an existing chart (dashboards / refresh).
|
||||
*/
|
||||
public function data(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $chart) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$savedQueryId = (int) ($chart['saved_query_id'] ?? 0);
|
||||
if ($savedQueryId <= 0) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart has no linked query.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Linked query not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Data source not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$variableValues = json_decode((string) $this->request->getPost('variables_json'), true);
|
||||
if (! is_array($variableValues)) {
|
||||
$variableValues = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, $variableValues);
|
||||
$rows = $result['rows'];
|
||||
|
||||
$renderer = new ChartRenderer();
|
||||
$payload = $renderer->buildPayload($chart, $rows);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'row_count' => count($rows),
|
||||
'execution_ms' => $result['execution_ms'],
|
||||
'cache_hit' => $result['cache_hit'],
|
||||
'chart_type' => (string) $chart['chart_type'],
|
||||
],
|
||||
'payload' => $payload,
|
||||
'csrf' => [
|
||||
'name' => csrf_token(),
|
||||
'hash' => csrf_hash(),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJson(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
$d = json_decode((string) $raw, true);
|
||||
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeDisplayConfig(mixed $raw): array
|
||||
{
|
||||
$d = $this->decodeJson($raw);
|
||||
if ($d === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowedKeys = [
|
||||
'title', 'subtitle', 'show_legend', 'show_data_labels', 'show_grid',
|
||||
'palette', 'palette_colors', 'color_mode', 'number_format', 'decimal_places',
|
||||
'x_axis_label', 'y_axis_label', 'y_min', 'y_max', 'stacked',
|
||||
'smooth', 'stepline', 'horizontal_bar', 'legend_position',
|
||||
'secondary_y_field', 'gauge_max', 'kpi_columns', 'table_preview_limit',
|
||||
];
|
||||
|
||||
$out = [];
|
||||
foreach ($allowedKeys as $k) {
|
||||
if (array_key_exists($k, $d)) {
|
||||
$out[$k] = $d[$k];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($out['color_mode']) && ! in_array($out['color_mode'], ['preset', 'custom'], true)) {
|
||||
unset($out['color_mode']);
|
||||
}
|
||||
|
||||
if (isset($out['palette_colors']) && is_array($out['palette_colors'])) {
|
||||
$clean = [];
|
||||
foreach ($out['palette_colors'] as $c) {
|
||||
$h = $this->sanitizeHexColor(is_scalar($c) ? (string) $c : '');
|
||||
if ($h !== null) {
|
||||
$clean[] = $h;
|
||||
}
|
||||
}
|
||||
$out['palette_colors'] = array_slice($clean, 0, 8);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function sanitizeHexColor(string $raw): ?string
|
||||
{
|
||||
$t = trim($raw);
|
||||
if ($t === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^#([0-9A-Fa-f]{6})$/', $t)) {
|
||||
return '#' . strtolower(substr($t, 1));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download query result as CSV or Excel (authenticated).
|
||||
*/
|
||||
public function export(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$format = strtolower((string) $this->request->getGet('format'));
|
||||
if (! in_array($format, ['csv', 'excel'], true)) {
|
||||
return $this->response->setStatusCode(400)->setBody('Invalid format. Use csv or excel.');
|
||||
}
|
||||
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->where('workspace_id', $workspaceId)->find($id);
|
||||
if (! $chart) {
|
||||
return $this->response->setStatusCode(404)->setBody('Chart not found.');
|
||||
}
|
||||
|
||||
$savedQueryId = (int) ($chart['saved_query_id'] ?? 0);
|
||||
if ($savedQueryId <= 0) {
|
||||
return $this->response->setStatusCode(422)->setBody('Chart has no linked query.');
|
||||
}
|
||||
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setStatusCode(404)->setBody('Query not found.');
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
return $this->response->setStatusCode(404)->setBody('Data source not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, []);
|
||||
$rows = $result['rows'];
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setStatusCode(422)->setBody($e->getMessage());
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if ($rows !== []) {
|
||||
$columns = array_keys($rows[0]);
|
||||
}
|
||||
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]+/', '_', (string) $chart['name']) ?: 'chart';
|
||||
$exportType = $format === 'excel' ? 'excel' : 'csv';
|
||||
|
||||
(new ChartExportModel())->logExport(
|
||||
$workspaceId,
|
||||
$userId,
|
||||
$exportType,
|
||||
$id,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
if ($format === 'csv') {
|
||||
$this->response->setHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||
$this->response->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '.csv"');
|
||||
|
||||
$fh = fopen('php://temp', 'r+');
|
||||
if ($columns !== []) {
|
||||
fputcsv($fh, $columns);
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$line = [];
|
||||
foreach ($columns as $c) {
|
||||
$line[] = $r[$c] ?? '';
|
||||
}
|
||||
fputcsv($fh, $line);
|
||||
}
|
||||
rewind($fh);
|
||||
$csv = stream_get_contents($fh);
|
||||
fclose($fh);
|
||||
if (str_starts_with($csv, "\xEF\xBB\xBF") === false) {
|
||||
$csv = "\xEF\xBB\xBF" . $csv;
|
||||
}
|
||||
|
||||
return $this->response->setBody($csv);
|
||||
}
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$grid = [];
|
||||
if ($columns !== []) {
|
||||
$grid[] = $columns;
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$line = [];
|
||||
foreach ($columns as $c) {
|
||||
$v = $r[$c] ?? '';
|
||||
$line[] = is_scalar($v) ? $v : json_encode($v);
|
||||
}
|
||||
$grid[] = $line;
|
||||
}
|
||||
if ($grid === []) {
|
||||
$sheet->setCellValue('A1', '');
|
||||
} else {
|
||||
$sheet->fromArray($grid);
|
||||
}
|
||||
|
||||
$this->response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$this->response->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '.xlsx"');
|
||||
|
||||
ob_start();
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$bin = ob_get_clean();
|
||||
|
||||
return $this->response->setBody($bin);
|
||||
}
|
||||
}
|
||||
497
app/Controllers/Dashboard/DashboardController.php
Normal file
497
app/Controllers/Dashboard/DashboardController.php
Normal file
@ -0,0 +1,497 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Dashboard;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DashboardModel;
|
||||
use App\Models\DashboardWidgetModel;
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashboards = (new DashboardModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('dashboard/index', [
|
||||
'title' => 'Dashboards | Chart-Board',
|
||||
'dashboards' => $dashboards,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'description' => 'permit_empty|max_length[2000]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$name = strip_tags((string) $this->request->getPost('name'));
|
||||
$desc = trim((string) $this->request->getPost('description')) ?: null;
|
||||
|
||||
$model = new DashboardModel();
|
||||
$slug = $model->generateUniqueSlug($workspaceId, $name);
|
||||
|
||||
$id = (int) $model->insert([
|
||||
'workspace_id' => $workspaceId,
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
'slug' => $slug,
|
||||
'layout_config' => null,
|
||||
'filters_config' => null,
|
||||
'refresh_interval' => 0,
|
||||
'theme' => 'system',
|
||||
'is_public' => 0,
|
||||
'public_token' => null,
|
||||
'public_password' => null,
|
||||
'public_expires_at'=> null,
|
||||
'is_pinned' => 0,
|
||||
'created_by' => $userId,
|
||||
], true);
|
||||
|
||||
AuditLogger::log(
|
||||
'dashboard.created',
|
||||
'dashboard',
|
||||
$id,
|
||||
null,
|
||||
['name' => $name],
|
||||
$workspaceId,
|
||||
$userId
|
||||
);
|
||||
|
||||
return redirect()->to('/dashboard/view/' . $id)->with('success', 'Dashboard created. Add widgets and arrange your layout.');
|
||||
}
|
||||
|
||||
public function viewBoard(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashModel = new DashboardModel();
|
||||
$dashboard = $dashModel->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return redirect()->to('/dashboard')->with('error', 'Dashboard not found.');
|
||||
}
|
||||
|
||||
$widgets = (new DashboardWidgetModel())->forDashboard($id);
|
||||
$charts = (new ChartModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('dashboard/view', [
|
||||
'title' => (string) $dashboard['name'] . ' | Chart-Board',
|
||||
'dashboard' => $dashboard,
|
||||
'widgets' => $widgets,
|
||||
'chartsList' => $charts,
|
||||
'dashBoot' => [
|
||||
'dashboardId' => $id,
|
||||
'workspaceTheme'=> (string) ($dashboard['theme'] ?? 'system'),
|
||||
'refreshSec' => max(0, (int) ($dashboard['refresh_interval'] ?? 0)),
|
||||
'widgets' => $this->widgetsBootPayload($widgets),
|
||||
'charts' => array_map(static fn (array $c) => [
|
||||
'id' => (int) $c['id'],
|
||||
'name' => (string) $c['name'],
|
||||
'type' => (string) ($c['chart_type'] ?? ''),
|
||||
], $charts),
|
||||
'urls' => [
|
||||
'chartData' => rtrim(base_url(), '/') . '/chart/',
|
||||
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
|
||||
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
|
||||
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',
|
||||
'savedQueryVars' => rtrim(base_url(), '/') . '/chart/saved-query/',
|
||||
],
|
||||
'csrf' => [
|
||||
'name' => csrf_token(),
|
||||
'hash' => csrf_hash(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $widgets
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function widgetsBootPayload(array $widgets): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($widgets as $w) {
|
||||
$cfg = null;
|
||||
if (! empty($w['widget_config'])) {
|
||||
$d = json_decode((string) $w['widget_config'], true);
|
||||
$cfg = is_array($d) ? $d : null;
|
||||
}
|
||||
$out[] = [
|
||||
'id' => (int) $w['id'],
|
||||
'chart_id' => isset($w['chart_id']) ? (int) $w['chart_id'] : null,
|
||||
'widget_type' => (string) ($w['widget_type'] ?? 'chart'),
|
||||
'title' => $w['title'] !== null && $w['title'] !== '' ? (string) $w['title'] : null,
|
||||
'grid_x' => (int) ($w['grid_x'] ?? 0),
|
||||
'grid_y' => (int) ($w['grid_y'] ?? 0),
|
||||
'grid_w' => (int) ($w['grid_w'] ?? 4),
|
||||
'grid_h' => (int) ($w['grid_h'] ?? 3),
|
||||
'content' => $w['content'] !== null ? (string) $w['content'] : null,
|
||||
'widget_config' => $cfg,
|
||||
'chart_name' => isset($w['chart_name']) ? (string) $w['chart_name'] : null,
|
||||
'chart_type' => isset($w['chart_type']) ? (string) $w['chart_type'] : null,
|
||||
'saved_query_id' => isset($w['saved_query_id']) ? (int) $w['saved_query_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function settings(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashboard = (new DashboardModel())->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return redirect()->to('/dashboard')->with('error', 'Dashboard not found.');
|
||||
}
|
||||
|
||||
return view('dashboard/settings', [
|
||||
'title' => 'Dashboard settings | Chart-Board',
|
||||
'dashboard' => $dashboard,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSettings(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DashboardModel();
|
||||
$dashboard = $model->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return redirect()->to('/dashboard')->with('error', 'Dashboard not found.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'description' => 'permit_empty|max_length[2000]',
|
||||
'theme' => 'required|in_list[light,dark,system]',
|
||||
'refresh_interval' => 'permit_empty|integer',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$oldSnap = [
|
||||
'name' => $dashboard['name'] ?? null,
|
||||
'theme' => $dashboard['theme'] ?? null,
|
||||
'refresh_interval' => $dashboard['refresh_interval'] ?? null,
|
||||
];
|
||||
$model->update($id, [
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'description' => trim((string) $this->request->getPost('description')) ?: null,
|
||||
'theme' => (string) $this->request->getPost('theme'),
|
||||
'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')),
|
||||
]);
|
||||
|
||||
AuditLogger::log(
|
||||
'dashboard.updated',
|
||||
'dashboard',
|
||||
$id,
|
||||
$oldSnap,
|
||||
[
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'theme' => (string) $this->request->getPost('theme'),
|
||||
'refresh_interval' => max(0, (int) $this->request->getPost('refresh_interval')),
|
||||
],
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DashboardModel();
|
||||
$dashboard = $model->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return redirect()->to('/dashboard')->with('error', 'Dashboard not found.');
|
||||
}
|
||||
|
||||
$confirm = trim((string) $this->request->getPost('confirm_name'));
|
||||
if ($confirm !== (string) $dashboard['name']) {
|
||||
return redirect()->back()->with('error', 'Type the dashboard name exactly to confirm deletion.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'dashboard.deleted',
|
||||
'dashboard',
|
||||
$id,
|
||||
['name' => $dashboard['name'] ?? null],
|
||||
null,
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$model->delete($id);
|
||||
|
||||
return redirect()->to('/dashboard')->with('success', 'Dashboard deleted.');
|
||||
}
|
||||
|
||||
public function pinToggle(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DashboardModel();
|
||||
$dashboard = $model->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return redirect()->to('/dashboard')->with('error', 'Dashboard not found.');
|
||||
}
|
||||
|
||||
$pinned = (int) ($dashboard['is_pinned'] ?? 0) === 1;
|
||||
$model->update($id, ['is_pinned' => $pinned ? 0 : 1]);
|
||||
|
||||
return redirect()->back()->with('success', 'Pin updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-update widget grid positions (JSON body or form).
|
||||
*/
|
||||
public function saveLayout(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashModel = new DashboardModel();
|
||||
$dashboard = $dashModel->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
// Do not use IncomingRequest::getJSON() here: the front-end posts
|
||||
// application/x-www-form-urlencoded (csrf + items_json), and getJSON()
|
||||
// decodes the *entire* body as JSON, which throws HTTPException.
|
||||
$items = null;
|
||||
$ct = strtolower($this->request->getHeaderLine('Content-Type'));
|
||||
if (str_contains($ct, 'application/json')) {
|
||||
$body = $this->request->getBody();
|
||||
if ($body !== null && $body !== '') {
|
||||
$decoded = json_decode($body, true);
|
||||
if (is_array($decoded)) {
|
||||
if (isset($decoded['items']) && is_array($decoded['items'])) {
|
||||
$items = $decoded['items'];
|
||||
} elseif (array_is_list($decoded)) {
|
||||
$items = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! is_array($items)) {
|
||||
$items = json_decode((string) $this->request->getPost('items_json'), true);
|
||||
}
|
||||
if (! is_array($items)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid layout payload.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$widgetModel = new DashboardWidgetModel();
|
||||
$db = $widgetModel->db;
|
||||
$db->transStart();
|
||||
|
||||
foreach ($items as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$wid = (int) ($row['id'] ?? 0);
|
||||
if ($wid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$w = $widgetModel->find($wid);
|
||||
if (! $w || (int) $w['dashboard_id'] !== $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$widgetModel->update($wid, [
|
||||
'grid_x' => max(0, min(255, (int) ($row['x'] ?? 0))),
|
||||
'grid_y' => max(0, min(255, (int) ($row['y'] ?? 0))),
|
||||
'grid_w' => max(1, min(12, (int) ($row['w'] ?? 4))),
|
||||
'grid_h' => max(1, min(24, (int) ($row['h'] ?? 3))),
|
||||
]);
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (! $db->transStatus()) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Could not save layout.'])->setStatusCode(500);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addWidget(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashModel = new DashboardModel();
|
||||
$dashboard = $dashModel->findForWorkspace($id, $workspaceId);
|
||||
|
||||
if (! $dashboard) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$type = (string) $this->request->getPost('widget_type');
|
||||
if (! in_array($type, ['chart', 'text', 'image', 'filter_date', 'filter_dropdown'], true)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid widget type.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$chartId = null;
|
||||
if ($type === 'chart') {
|
||||
$chartId = (int) $this->request->getPost('chart_id');
|
||||
$chart = (new ChartModel())->where('workspace_id', $workspaceId)->find($chartId);
|
||||
if (! $chart) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart not found.'])->setStatusCode(404);
|
||||
}
|
||||
}
|
||||
|
||||
$title = trim((string) $this->request->getPost('title')) ?: null;
|
||||
$content = trim((string) $this->request->getPost('content')) ?: null;
|
||||
$widgetConfig = $this->request->getPost('widget_config');
|
||||
|
||||
if ($type === 'text' && ($content === null || $content === '')) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Text widgets need content.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
if ($type === 'image' && ($content === null || $content === '')) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Image widgets need a URL.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
if ($type === 'image' && ! $this->isSafeImageUrl((string) $content)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Only http(s) image URLs are allowed.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
if ($type === 'image') {
|
||||
$fit = (string) $this->request->getPost('image_fit');
|
||||
if (! in_array($fit, ['cover', 'contain', 'fill', 'scale-down'], true)) {
|
||||
$fit = 'cover';
|
||||
}
|
||||
$widgetConfig = json_encode(['object_fit' => $fit], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if ($type === 'filter_date') {
|
||||
$sv = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_start_var')) ?: 'date_from';
|
||||
$ev = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_end_var')) ?: 'date_to';
|
||||
$widgetConfig = json_encode(['start_var' => $sv, 'end_var' => $ev], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if ($type === 'filter_dropdown') {
|
||||
$vn = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $this->request->getPost('filter_var_name')) ?: 'region';
|
||||
$optRaw = (string) $this->request->getPost('filter_options');
|
||||
$options = array_values(array_filter(array_map('trim', explode(',', $optRaw)), static fn ($s) => $s !== ''));
|
||||
$widgetConfig = json_encode(['var_name' => $vn, 'options' => $options], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
$configJson = $this->normalizeWidgetConfig($widgetConfig, $type);
|
||||
|
||||
$widgetModel = new DashboardWidgetModel();
|
||||
$maxRow = $widgetModel->selectMax('sort_order')->where('dashboard_id', $id)->first();
|
||||
$maxOrder = (int) ($maxRow['sort_order'] ?? 0);
|
||||
|
||||
$newId = (int) $widgetModel->insert([
|
||||
'dashboard_id' => $id,
|
||||
'chart_id' => $chartId,
|
||||
'widget_type' => $type,
|
||||
'title' => $title,
|
||||
'grid_x' => 0,
|
||||
'grid_y' => 0,
|
||||
'grid_w' => $type === 'chart' ? 6 : 4,
|
||||
'grid_h' => $type === 'chart' ? 4 : 2,
|
||||
'content' => $type === 'chart' ? null : $content,
|
||||
'widget_config' => $configJson,
|
||||
'sort_order' => $maxOrder + 1,
|
||||
], true);
|
||||
|
||||
$row = $widgetModel->forDashboard($id);
|
||||
$created = null;
|
||||
foreach ($row as $r) {
|
||||
if ((int) $r['id'] === $newId) {
|
||||
$created = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'widget' => $created,
|
||||
'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeWidget(int $widgetId)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$widgetModel = new DashboardWidgetModel();
|
||||
$w = $widgetModel->find($widgetId);
|
||||
|
||||
if (! $w) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Widget not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$dashboard = (new DashboardModel())->findForWorkspace((int) $w['dashboard_id'], $workspaceId);
|
||||
if (! $dashboard) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$widgetModel->delete($widgetId);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
|
||||
]);
|
||||
}
|
||||
|
||||
private function isSafeImageUrl(string $url): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('#^https?://#i', $url) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filter_var($url, FILTER_VALIDATE_URL) !== false;
|
||||
}
|
||||
|
||||
private function normalizeWidgetConfig(mixed $raw, string $type): ?string
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
if ($type === 'filter_dropdown') {
|
||||
return json_encode(['var_name' => 'filter', 'options' => []], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if ($type === 'filter_date') {
|
||||
return json_encode(['start_var' => 'date_from', 'end_var' => 'date_to'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if ($type === 'image') {
|
||||
return json_encode(['object_fit' => 'cover'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
$d = json_decode($raw, true);
|
||||
|
||||
return is_array($d) ? json_encode($d, JSON_UNESCAPED_UNICODE) : null;
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
return json_encode($raw, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
724
app/Controllers/DataSource/DataSourceController.php
Normal file
724
app/Controllers/DataSource/DataSourceController.php
Normal file
@ -0,0 +1,724 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\DataSource;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Libraries\ConnectionFactory;
|
||||
use App\Models\DataSourceModel;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Throwable;
|
||||
|
||||
class DataSourceController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dataSources = (new DataSourceModel())->forWorkspace($workspaceId);
|
||||
|
||||
return view('datasource/index', [
|
||||
'title' => 'Data Sources | Chart-Board',
|
||||
'dataSources' => $dataSources,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('datasource/create', [
|
||||
'title' => 'Create Data Source | Chart-Board',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return redirect()->to('/datasource')->with('error', 'Data source not found.');
|
||||
}
|
||||
|
||||
$proof = $this->buildConnectionProof($dataSource);
|
||||
|
||||
return view('datasource/show', [
|
||||
'title' => 'Data Source Details | Chart-Board',
|
||||
'dataSource' => $dataSource,
|
||||
'proof' => $proof,
|
||||
]);
|
||||
}
|
||||
|
||||
public function proof(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Data source not found.',
|
||||
])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$proof = $this->buildConnectionProof($dataSource);
|
||||
$html = view('datasource/_proof_content', [
|
||||
'dataSource' => $dataSource,
|
||||
'proof' => $proof,
|
||||
]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Proof refreshed.',
|
||||
'proof' => $proof,
|
||||
'html' => $html,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$rules = $this->baseRules();
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$payload = $this->buildPayload($workspaceId);
|
||||
$payload['created_by'] = (int) $this->session->get('user_id');
|
||||
$payload['status'] = 'untested';
|
||||
$payload['error_message'] = null;
|
||||
$payload['last_tested_at'] = null;
|
||||
|
||||
$dsModel = new DataSourceModel();
|
||||
$newId = (int) $dsModel->insert($payload, true);
|
||||
|
||||
if ($newId > 0) {
|
||||
AuditLogger::log(
|
||||
'datasource.created',
|
||||
'data_source',
|
||||
$newId,
|
||||
null,
|
||||
$this->snapshotDataSource($payload),
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->to('/datasource')->with('success', 'Data source created.');
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dataSource = (new DataSourceModel())
|
||||
->where('workspace_id', $workspaceId)
|
||||
->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return redirect()->to('/datasource')->with('error', 'Data source not found.');
|
||||
}
|
||||
|
||||
return view('datasource/edit', [
|
||||
'title' => 'Edit Data Source | Chart-Board',
|
||||
'dataSource' => $dataSource,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return redirect()->to('/datasource')->with('error', 'Data source not found.');
|
||||
}
|
||||
|
||||
$rules = $this->baseRules(false);
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$payload = $this->buildPayload($workspaceId);
|
||||
|
||||
// Keep previously encrypted secrets when user leaves masked value unchanged.
|
||||
if (trim((string) $this->request->getPost('password')) === '') {
|
||||
unset($payload['password']);
|
||||
}
|
||||
if (trim((string) $this->request->getPost('api_auth_value')) === '') {
|
||||
unset($payload['api_auth_value']);
|
||||
}
|
||||
|
||||
$payload['status'] = 'untested';
|
||||
$payload['error_message'] = null;
|
||||
$payload['last_tested_at'] = null;
|
||||
|
||||
$oldSnap = $this->snapshotDataSource($dataSource);
|
||||
$model->update($id, $payload);
|
||||
$newRow = $model->find($id) ?? [];
|
||||
AuditLogger::log(
|
||||
'datasource.updated',
|
||||
'data_source',
|
||||
$id,
|
||||
$oldSnap,
|
||||
$this->snapshotDataSource($newRow),
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/datasource')->with('success', 'Data source updated.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return redirect()->to('/datasource')->with('error', 'Data source not found.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'datasource.deleted',
|
||||
'data_source',
|
||||
$id,
|
||||
$this->snapshotDataSource($dataSource),
|
||||
null,
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$model->delete($id);
|
||||
return redirect()->to('/datasource')->with('success', 'Data source deleted.');
|
||||
}
|
||||
|
||||
public function test()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$id = (int) $this->request->getPost('id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $id > 0
|
||||
? $model->where('workspace_id', $workspaceId)->find($id)
|
||||
: null;
|
||||
|
||||
$payload = $dataSource ?: $this->buildPayload($workspaceId);
|
||||
$result = ['success' => false, 'message' => 'Connection test failed.'];
|
||||
|
||||
try {
|
||||
$connector = (new ConnectionFactory())->make((string) $payload['type']);
|
||||
$result = $connector->test($payload);
|
||||
} catch (Throwable $e) {
|
||||
$result = ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
|
||||
if ($dataSource) {
|
||||
$model->update($id, [
|
||||
'status' => $result['success'] ? 'connected' : 'failed',
|
||||
'error_message' => $result['success'] ? null : $result['message'],
|
||||
'last_tested_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response->setJSON($result);
|
||||
}
|
||||
|
||||
return redirect()->back()->with(
|
||||
$result['success'] ? 'success' : 'error',
|
||||
$result['message']
|
||||
);
|
||||
}
|
||||
|
||||
public function schema(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new DataSourceModel();
|
||||
$dataSource = $model->where('workspace_id', $workspaceId)->find($id);
|
||||
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Data source not found.',
|
||||
])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$cacheKey = 'ds_schema_' . $id . '_' . md5((string) ($dataSource['updated_at'] ?? ''));
|
||||
$cached = cache()->get($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Schema loaded from cache.',
|
||||
'data' => $cached,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$schemaData = $this->loadSchema((string) $dataSource['type'], $dataSource);
|
||||
cache()->save($cacheKey, $schemaData, 300);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => 'Schema loaded successfully.',
|
||||
'data' => $schemaData,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
private function baseRules(bool $requirePassword = true): array
|
||||
{
|
||||
$passwordRule = $requirePassword ? 'permit_empty|max_length[1000]' : 'permit_empty|max_length[1000]';
|
||||
|
||||
return [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'type' => 'required|in_list[mysql,postgresql,mongodb,rest_api,csv]',
|
||||
'host' => 'permit_empty|max_length[255]',
|
||||
'port' => 'permit_empty|integer|greater_than_equal_to[1]|less_than_equal_to[65535]',
|
||||
'database_name' => 'permit_empty|max_length[150]',
|
||||
'username' => 'permit_empty|max_length[150]',
|
||||
'password' => $passwordRule,
|
||||
'connection_uri' => 'permit_empty|max_length[5000]',
|
||||
'api_base_url' => 'permit_empty|max_length[500]|valid_url_strict',
|
||||
'api_method' => 'permit_empty|in_list[GET,POST]',
|
||||
'api_auth_type' => 'permit_empty|in_list[none,bearer,basic,api_key]',
|
||||
'api_auth_value' => 'permit_empty|max_length[5000]',
|
||||
'csv_file_path' => 'permit_empty|max_length[500]',
|
||||
'csv_delimiter' => 'permit_empty|max_length[1]',
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPayload(int $workspaceId): array
|
||||
{
|
||||
$headersRaw = trim((string) $this->request->getPost('api_headers'));
|
||||
|
||||
$data = [
|
||||
'workspace_id' => $workspaceId,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'type' => (string) $this->request->getPost('type'),
|
||||
'host' => trim((string) $this->request->getPost('host')) ?: null,
|
||||
'port' => $this->request->getPost('port') !== null && $this->request->getPost('port') !== '' ? (int) $this->request->getPost('port') : null,
|
||||
'database_name' => trim((string) $this->request->getPost('database_name')) ?: null,
|
||||
'username' => trim((string) $this->request->getPost('username')) ?: null,
|
||||
'password' => (string) $this->request->getPost('password'),
|
||||
'connection_uri' => trim((string) $this->request->getPost('connection_uri')) ?: null,
|
||||
'ssl_enabled' => $this->request->getPost('ssl_enabled') ? 1 : 0,
|
||||
'ssl_ca' => trim((string) $this->request->getPost('ssl_ca')) ?: null,
|
||||
'api_base_url' => trim((string) $this->request->getPost('api_base_url')) ?: null,
|
||||
'api_method' => (string) ($this->request->getPost('api_method') ?: 'GET'),
|
||||
'api_auth_type' => (string) ($this->request->getPost('api_auth_type') ?: 'none'),
|
||||
'api_auth_value' => (string) $this->request->getPost('api_auth_value'),
|
||||
'api_headers' => $headersRaw !== '' ? json_encode($this->sanitizeHeadersJson($headersRaw)) : null,
|
||||
'csv_file_path' => trim((string) $this->request->getPost('csv_file_path')) ?: null,
|
||||
'csv_delimiter' => trim((string) $this->request->getPost('csv_delimiter')) ?: ',',
|
||||
];
|
||||
|
||||
return $this->normalizeForType($data);
|
||||
}
|
||||
|
||||
private function normalizeForType(array $data): array
|
||||
{
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
if (in_array($type, ['mysql', 'postgresql'], true)) {
|
||||
$data['api_base_url'] = null;
|
||||
$data['api_auth_type'] = 'none';
|
||||
$data['api_auth_value'] = null;
|
||||
$data['api_headers'] = null;
|
||||
$data['csv_file_path'] = null;
|
||||
$data['csv_delimiter'] = ',';
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ($type === 'mongodb') {
|
||||
$data['database_name'] = null;
|
||||
$data['api_base_url'] = null;
|
||||
$data['api_auth_type'] = 'none';
|
||||
$data['api_auth_value'] = null;
|
||||
$data['api_headers'] = null;
|
||||
$data['csv_file_path'] = null;
|
||||
$data['csv_delimiter'] = ',';
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ($type === 'rest_api') {
|
||||
$data['host'] = null;
|
||||
$data['port'] = null;
|
||||
$data['database_name'] = null;
|
||||
$data['username'] = null;
|
||||
$data['password'] = null;
|
||||
$data['connection_uri'] = null;
|
||||
$data['ssl_enabled'] = 0;
|
||||
$data['ssl_ca'] = null;
|
||||
$data['csv_file_path'] = null;
|
||||
$data['csv_delimiter'] = ',';
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ($type === 'csv') {
|
||||
$data['host'] = null;
|
||||
$data['port'] = null;
|
||||
$data['database_name'] = null;
|
||||
$data['username'] = null;
|
||||
$data['password'] = null;
|
||||
$data['connection_uri'] = null;
|
||||
$data['ssl_enabled'] = 0;
|
||||
$data['ssl_ca'] = null;
|
||||
$data['api_base_url'] = null;
|
||||
$data['api_auth_type'] = 'none';
|
||||
$data['api_auth_value'] = null;
|
||||
$data['api_headers'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function sanitizeHeadersJson(string $json): array
|
||||
{
|
||||
$decoded = json_decode($json, true);
|
||||
if (! is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($decoded as $key => $value) {
|
||||
$cleanKey = strip_tags((string) $key);
|
||||
if ($cleanKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean[$cleanKey] = strip_tags((string) $value);
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function buildConnectionProof(array $dataSource): array
|
||||
{
|
||||
$proof = [
|
||||
'success' => false,
|
||||
'message' => 'Connection proof not available.',
|
||||
'meta' => [],
|
||||
'data' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$connector = (new ConnectionFactory())->make((string) $dataSource['type']);
|
||||
$test = $connector->test($dataSource);
|
||||
$proof['success'] = (bool) ($test['success'] ?? false);
|
||||
$proof['message'] = (string) ($test['message'] ?? $proof['message']);
|
||||
} catch (Throwable $e) {
|
||||
$proof['message'] = $e->getMessage();
|
||||
return $proof;
|
||||
}
|
||||
|
||||
if (! $proof['success']) {
|
||||
return $proof;
|
||||
}
|
||||
|
||||
$type = (string) $dataSource['type'];
|
||||
|
||||
if (in_array($type, ['mysql', 'postgresql'], true)) {
|
||||
try {
|
||||
$schema = $this->loadSchema($type, $dataSource);
|
||||
$tables = $schema['tables'] ?? [];
|
||||
$proof['meta']['table_count'] = count($tables);
|
||||
$proof['data']['tables'] = $tables;
|
||||
} catch (Throwable $e) {
|
||||
$proof['message'] = 'Connected, but unable to load schema: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $proof;
|
||||
}
|
||||
|
||||
if ($type === 'rest_api') {
|
||||
try {
|
||||
$preview = $this->fetchApiPreview($dataSource);
|
||||
$proof['meta']['http_code'] = $preview['http_code'];
|
||||
$proof['data']['api_preview'] = $preview['data'];
|
||||
$proof['data']['api_raw'] = $preview['raw'];
|
||||
} catch (Throwable $e) {
|
||||
$proof['message'] = 'Connected, but unable to fetch API preview: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $proof;
|
||||
}
|
||||
|
||||
if ($type === 'csv') {
|
||||
try {
|
||||
$csv = $this->loadCsvPreview($dataSource);
|
||||
$proof['meta']['row_count_preview'] = count($csv['rows']);
|
||||
$proof['data']['csv_headers'] = $csv['headers'];
|
||||
$proof['data']['csv_rows'] = $csv['rows'];
|
||||
} catch (Throwable $e) {
|
||||
$proof['message'] = 'Connected, but unable to read CSV preview: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $proof;
|
||||
}
|
||||
|
||||
private function loadSchema(string $type, array $config): array
|
||||
{
|
||||
if ($type === 'mysql') {
|
||||
return $this->loadMySqlSchema($config);
|
||||
}
|
||||
|
||||
if ($type === 'postgresql') {
|
||||
return $this->loadPostgreSqlSchema($config);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Schema browsing is currently supported only for MySQL and PostgreSQL sources.');
|
||||
}
|
||||
|
||||
private function loadMySqlSchema(array $config): array
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||
$config['host'] ?? '',
|
||||
(int) ($config['port'] ?? 3306),
|
||||
$config['database_name'] ?? ''
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
(string) ($config['username'] ?? ''),
|
||||
(string) ($config['password'] ?? ''),
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new \RuntimeException('MySQL schema connection failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$tablesStmt = $pdo->query('SHOW TABLES');
|
||||
$tableRows = $tablesStmt ? $tablesStmt->fetchAll(PDO::FETCH_NUM) : [];
|
||||
$tables = [];
|
||||
|
||||
foreach ($tableRows as $tableRow) {
|
||||
$tableName = (string) ($tableRow[0] ?? '');
|
||||
if ($tableName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columnsStmt = $pdo->prepare('SHOW COLUMNS FROM `' . str_replace('`', '``', $tableName) . '`');
|
||||
$columnsStmt->execute();
|
||||
$columnsRows = $columnsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$columns = [];
|
||||
foreach ($columnsRows as $column) {
|
||||
$columns[] = [
|
||||
'name' => (string) ($column['Field'] ?? ''),
|
||||
'type' => (string) ($column['Type'] ?? ''),
|
||||
'nullable' => (string) ($column['Null'] ?? '') === 'YES',
|
||||
];
|
||||
}
|
||||
|
||||
$tables[] = [
|
||||
'name' => $tableName,
|
||||
'columns' => $columns,
|
||||
];
|
||||
}
|
||||
|
||||
return ['tables' => $tables];
|
||||
}
|
||||
|
||||
private function loadPostgreSqlSchema(array $config): array
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'pgsql:host=%s;port=%d;dbname=%s',
|
||||
$config['host'] ?? '',
|
||||
(int) ($config['port'] ?? 5432),
|
||||
$config['database_name'] ?? ''
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
(string) ($config['username'] ?? ''),
|
||||
(string) ($config['password'] ?? ''),
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new \RuntimeException('PostgreSQL schema connection failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$tableStmt = $pdo->query(
|
||||
"SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name"
|
||||
);
|
||||
$tableRows = $tableStmt ? $tableStmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
$tables = [];
|
||||
|
||||
$columnStmt = $pdo->prepare(
|
||||
"SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = :table
|
||||
ORDER BY ordinal_position"
|
||||
);
|
||||
|
||||
foreach ($tableRows as $row) {
|
||||
$tableName = (string) ($row['table_name'] ?? '');
|
||||
if ($tableName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columnStmt->execute(['table' => $tableName]);
|
||||
$columnsRows = $columnStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$columns = [];
|
||||
foreach ($columnsRows as $column) {
|
||||
$columns[] = [
|
||||
'name' => (string) ($column['column_name'] ?? ''),
|
||||
'type' => (string) ($column['data_type'] ?? ''),
|
||||
'nullable' => (string) ($column['is_nullable'] ?? '') === 'YES',
|
||||
];
|
||||
}
|
||||
|
||||
$tables[] = [
|
||||
'name' => $tableName,
|
||||
'columns' => $columns,
|
||||
];
|
||||
}
|
||||
|
||||
return ['tables' => $tables];
|
||||
}
|
||||
|
||||
private function fetchApiPreview(array $config): array
|
||||
{
|
||||
$url = (string) ($config['api_base_url'] ?? '');
|
||||
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new \RuntimeException('A valid API base URL is required.');
|
||||
}
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
$authType = (string) ($config['api_auth_type'] ?? 'none');
|
||||
$authValue = (string) ($config['api_auth_value'] ?? '');
|
||||
|
||||
if ($authType === 'bearer' && $authValue !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $authValue;
|
||||
} elseif ($authType === 'api_key' && $authValue !== '') {
|
||||
$headers[] = 'X-API-KEY: ' . $authValue;
|
||||
} elseif ($authType === 'basic' && $authValue !== '') {
|
||||
$headers[] = 'Authorization: Basic ' . base64_encode($authValue);
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => (string) ($config['api_method'] ?? 'GET'),
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
$raw = (string) curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error !== '') {
|
||||
throw new \RuntimeException($error);
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
$preview = is_array($decoded) ? $decoded : null;
|
||||
if (is_array($preview)) {
|
||||
// Keep preview concise for UI readability.
|
||||
$preview = $this->limitDepth($preview, 2, 20);
|
||||
}
|
||||
|
||||
return [
|
||||
'http_code' => $httpCode,
|
||||
'data' => $preview,
|
||||
'raw' => mb_substr($raw, 0, 2000),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadCsvPreview(array $config): array
|
||||
{
|
||||
$path = (string) ($config['csv_file_path'] ?? '');
|
||||
if ($path === '' || ! is_file($path)) {
|
||||
throw new \RuntimeException('CSV file not found.');
|
||||
}
|
||||
|
||||
$delimiter = (string) ($config['csv_delimiter'] ?? ',');
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Unable to open CSV file.');
|
||||
}
|
||||
|
||||
$headers = fgetcsv($handle, 0, $delimiter);
|
||||
if ($headers === false) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('CSV appears empty.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$limit = 20;
|
||||
while (($row = fgetcsv($handle, 0, $delimiter)) !== false && count($rows) < $limit) {
|
||||
$rowAssoc = [];
|
||||
foreach ($headers as $i => $header) {
|
||||
$rowAssoc[(string) $header] = (string) ($row[$i] ?? '');
|
||||
}
|
||||
$rows[] = $rowAssoc;
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return [
|
||||
'headers' => array_map(static fn($h) => (string) $h, $headers),
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotDataSource(array $row): array
|
||||
{
|
||||
$keys = ['id', 'name', 'type', 'host', 'port', 'database_name', 'username', 'api_base_url', 'status'];
|
||||
|
||||
$out = [];
|
||||
foreach ($keys as $k) {
|
||||
if (array_key_exists($k, $row)) {
|
||||
$out[$k] = $row[$k];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function limitDepth(array $data, int $depth, int $limit): array
|
||||
{
|
||||
if ($depth <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if ($count >= $limit) {
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
if (is_array($value)) {
|
||||
$result[$key] = $this->limitDepth($value, $depth - 1, $limit);
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
11
app/Controllers/Home.php
Normal file
11
app/Controllers/Home.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
class Home extends BaseController
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
return view('welcome_message');
|
||||
}
|
||||
}
|
||||
93
app/Controllers/ProfileController.php
Normal file
93
app/Controllers/ProfileController.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\UserModel;
|
||||
|
||||
class ProfileController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($userId);
|
||||
|
||||
return view('profile/index', [
|
||||
'title' => 'Profile | Chart-Board',
|
||||
'user' => $user,
|
||||
'plainApiToken' => session()->getFlashdata('plain_api_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'avatar' => 'permit_empty|uploaded[avatar]|max_size[avatar,2048]|is_image[avatar]|mime_in[avatar,image/jpg,image/jpeg,image/png,image/webp]',
|
||||
];
|
||||
|
||||
$isAvatarUpload = $this->request->getFile('avatar') && $this->request->getFile('avatar')->isValid();
|
||||
if (! $isAvatarUpload) {
|
||||
unset($rules['avatar']);
|
||||
}
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
];
|
||||
|
||||
if ($isAvatarUpload) {
|
||||
$avatar = $this->request->getFile('avatar');
|
||||
$avatarName = $avatar->getRandomName();
|
||||
$avatar->move(WRITEPATH . 'uploads/avatars', $avatarName);
|
||||
$updateData['avatar'] = 'writable/uploads/avatars/' . $avatarName;
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$userModel->update($userId, $updateData);
|
||||
$this->session->set('name', $updateData['name']);
|
||||
|
||||
return redirect()->to('/profile')->with('success', 'Profile updated.');
|
||||
}
|
||||
|
||||
public function changePassword()
|
||||
{
|
||||
$rules = [
|
||||
'current_password' => 'required|min_length[8]|max_length[255]',
|
||||
'new_password' => 'required|min_length[8]|max_length[255]',
|
||||
'confirm_password' => 'required|matches[new_password]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($userId);
|
||||
|
||||
if (! $user || ! password_verify((string) $this->request->getPost('current_password'), (string) $user['password'])) {
|
||||
return redirect()->back()->with('error', 'Current password is incorrect.');
|
||||
}
|
||||
|
||||
$userModel->update($userId, ['password' => (string) $this->request->getPost('new_password')]);
|
||||
return redirect()->to('/profile')->with('success', 'Password changed successfully.');
|
||||
}
|
||||
|
||||
public function generateApiToken()
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$plainToken = bin2hex(random_bytes(32));
|
||||
$hashedToken = hash('sha256', $plainToken);
|
||||
|
||||
$userModel = new UserModel();
|
||||
$userModel->update($userId, ['api_token' => $hashedToken]);
|
||||
|
||||
return redirect()->to('/profile')->with('success', 'API token regenerated. Copy it now.')
|
||||
->with('plain_api_token', $plainToken);
|
||||
}
|
||||
}
|
||||
1026
app/Controllers/Query/QueryController.php
Normal file
1026
app/Controllers/Query/QueryController.php
Normal file
File diff suppressed because it is too large
Load Diff
231
app/Controllers/Settings/SettingsController.php
Normal file
231
app/Controllers/Settings/SettingsController.php
Normal file
@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Settings;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\SettingsModel;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
use App\Models\WorkspaceModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Email as EmailConfig;
|
||||
use Config\Services;
|
||||
|
||||
class SettingsController extends BaseController
|
||||
{
|
||||
private function ensureWorkspaceMembership(int $workspaceId): bool
|
||||
{
|
||||
return (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $this->session->get('user_id'))
|
||||
->first() !== null;
|
||||
}
|
||||
|
||||
public function workspace()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
if (! $this->ensureWorkspaceMembership($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied.');
|
||||
}
|
||||
|
||||
$workspace = (new WorkspaceModel())->find($workspaceId);
|
||||
if (! $workspace) {
|
||||
return redirect()->to('/workspace')->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
$defaultTheme = (new SettingsModel())->getValue($workspaceId, 'default_theme', 'system') ?? 'system';
|
||||
|
||||
return view('settings/workspace', [
|
||||
'title' => 'Workspace preferences | Chart-Board',
|
||||
'workspace' => $workspace,
|
||||
'default_theme' => $defaultTheme,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveWorkspace()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
if (! $this->ensureWorkspaceMembership($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied.');
|
||||
}
|
||||
|
||||
$workspaceModel = new WorkspaceModel();
|
||||
$workspace = $workspaceModel->find($workspaceId);
|
||||
if (! $workspace) {
|
||||
return redirect()->to('/workspace')->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'description' => 'permit_empty|max_length[1000]',
|
||||
'timezone' => 'required|max_length[80]',
|
||||
'default_refresh' => 'required|in_list[60,300,900,3600]',
|
||||
'is_active' => 'required|in_list[0,1]',
|
||||
'default_theme' => 'required|in_list[light,dark,system]',
|
||||
'logo' => 'permit_empty|uploaded[logo]|max_size[logo,512]|is_image[logo]|mime_in[logo,image/jpg,image/jpeg,image/png,image/webp,image/svg+xml]',
|
||||
];
|
||||
|
||||
$hasLogo = $this->request->getFile('logo') && $this->request->getFile('logo')->isValid();
|
||||
if (! $hasLogo) {
|
||||
unset($rules['logo']);
|
||||
}
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$update = [
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'slug' => (string) $this->request->getPost('slug'),
|
||||
'description' => strip_tags((string) $this->request->getPost('description')),
|
||||
'timezone' => (string) $this->request->getPost('timezone'),
|
||||
'default_refresh' => (int) $this->request->getPost('default_refresh'),
|
||||
'is_active' => (int) $this->request->getPost('is_active'),
|
||||
];
|
||||
|
||||
if ($hasLogo) {
|
||||
$logo = $this->request->getFile('logo');
|
||||
$logoName = $logo->getRandomName();
|
||||
$logo->move(WRITEPATH . 'uploads/workspaces', $logoName);
|
||||
$update['logo'] = 'writable/uploads/workspaces/' . $logoName;
|
||||
}
|
||||
|
||||
$workspaceModel->update($workspaceId, $update);
|
||||
|
||||
(new SettingsModel())->setValue(
|
||||
$workspaceId,
|
||||
'default_theme',
|
||||
(string) $this->request->getPost('default_theme'),
|
||||
'string'
|
||||
);
|
||||
|
||||
AuditLogger::log(
|
||||
'settings.workspace_saved',
|
||||
'workspace',
|
||||
$workspaceId,
|
||||
null,
|
||||
['name' => $update['name'], 'default_theme' => (string) $this->request->getPost('default_theme')],
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/settings/workspace')->with('success', 'Workspace preferences saved.');
|
||||
}
|
||||
|
||||
public function notifications()
|
||||
{
|
||||
$settings = new SettingsModel();
|
||||
$slackUrl = $settings->getValue(null, 'slack_webhook_url', '') ?? '';
|
||||
$emailConfig = new EmailConfig();
|
||||
|
||||
return view('settings/notifications', [
|
||||
'title' => 'Notification settings | Chart-Board',
|
||||
'smtpHost' => $emailConfig->SMTPHost,
|
||||
'smtpPort' => $emailConfig->SMTPPort,
|
||||
'smtpUser' => $emailConfig->SMTPUser,
|
||||
'smtpCrypto' => $emailConfig->SMTPCrypto,
|
||||
'fromEmail' => $emailConfig->fromEmail,
|
||||
'slackWebhookUrl' => $slackUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveSlackWebhook(): ResponseInterface
|
||||
{
|
||||
$rules = ['slack_webhook_url' => 'permit_empty|max_length[2000]'];
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$url = trim((string) $this->request->getPost('slack_webhook_url'));
|
||||
(new SettingsModel())->setValue(null, 'slack_webhook_url', $url, 'string');
|
||||
|
||||
AuditLogger::log(
|
||||
'settings.slack_webhook_saved',
|
||||
'settings',
|
||||
null,
|
||||
null,
|
||||
['slack_webhook_url' => $url !== '' ? '[redacted]' : 'cleared'],
|
||||
(int) $this->session->get('active_workspace_id') ?: null,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/settings/notifications')->with('success', 'Slack webhook URL saved.');
|
||||
}
|
||||
|
||||
public function testSmtp(): ResponseInterface
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$user = (new \App\Models\UserModel())->find($userId);
|
||||
if (! $user) {
|
||||
return redirect()->back()->with('error', 'User not found.');
|
||||
}
|
||||
|
||||
$to = (string) ($user['email'] ?? '');
|
||||
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return redirect()->back()->with('error', 'Your profile email is invalid.');
|
||||
}
|
||||
|
||||
$config = new EmailConfig();
|
||||
$email = Services::email();
|
||||
$email->setFrom($config->fromEmail, $config->fromName);
|
||||
$email->setTo($to);
|
||||
$email->setSubject('Chart-Board SMTP test');
|
||||
$email->setMessage('<p>This is a test message from Chart-Board. If you received it, SMTP is configured correctly.</p>');
|
||||
|
||||
if (! $email->send()) {
|
||||
return redirect()->back()->with('error', 'SMTP test failed. Check server mail configuration.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Test email sent to your account email address.');
|
||||
}
|
||||
|
||||
public function testSlack(): ResponseInterface
|
||||
{
|
||||
$url = trim((string) (new SettingsModel())->getValue(null, 'slack_webhook_url', '') ?? '');
|
||||
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return redirect()->back()->with('error', 'Save a valid Slack webhook URL first.');
|
||||
}
|
||||
|
||||
$payload = json_encode(['text' => 'Chart-Board: Slack webhook test from ' . date('c')], JSON_UNESCAPED_UNICODE);
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return redirect()->back()->with('error', 'Could not start HTTP request.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
return redirect()->back()->with('error', 'Slack returned HTTP ' . $code . '.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Slack test message sent.');
|
||||
}
|
||||
|
||||
public function saveTheme()
|
||||
{
|
||||
$rules = ['theme' => 'required|in_list[light,dark,system]'];
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->with('error', 'Invalid theme.');
|
||||
}
|
||||
|
||||
$theme = (string) $this->request->getPost('theme');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
if ($userId <= 0) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
(new \App\Models\UserModel())->skipValidation(true)->update($userId, ['theme_preference' => $theme]);
|
||||
$this->session->set('theme_preference', $theme);
|
||||
|
||||
return redirect()->back()->with('success', 'Theme preference saved.');
|
||||
}
|
||||
}
|
||||
184
app/Controllers/Share/LinkController.php
Normal file
184
app/Controllers/Share/LinkController.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Share;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DashboardModel;
|
||||
use App\Models\SharedLinkModel;
|
||||
|
||||
/**
|
||||
* Authenticated: create / revoke share links.
|
||||
*/
|
||||
class LinkController extends BaseController
|
||||
{
|
||||
public function generate()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
|
||||
$rules = [
|
||||
'type' => 'required|in_list[dashboard,chart]',
|
||||
'resource_id' => 'required|integer',
|
||||
'password' => 'permit_empty|max_length[200]',
|
||||
'expires_at' => 'permit_empty|max_length[40]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response->setJSON(['success' => false, 'errors' => $this->validator->getErrors()])->setStatusCode(422);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$type = (string) $this->request->getPost('type');
|
||||
$resourceId = (int) $this->request->getPost('resource_id');
|
||||
|
||||
if ($type === 'dashboard') {
|
||||
$d = (new DashboardModel())->findForWorkspace($resourceId, $workspaceId);
|
||||
if (! $d) {
|
||||
return $this->ajaxOrRedirect(false, 'Dashboard not found.');
|
||||
}
|
||||
} else {
|
||||
$c = (new ChartModel())->where('workspace_id', $workspaceId)->find($resourceId);
|
||||
if (! $c) {
|
||||
return $this->ajaxOrRedirect(false, 'Chart not found.');
|
||||
}
|
||||
}
|
||||
|
||||
$pwd = trim((string) $this->request->getPost('password'));
|
||||
$hash = $pwd !== '' ? password_hash($pwd, PASSWORD_DEFAULT) : null;
|
||||
|
||||
$expRaw = trim((string) $this->request->getPost('expires_at'));
|
||||
$expiresAt = null;
|
||||
if ($expRaw !== '') {
|
||||
$ts = strtotime($expRaw);
|
||||
if ($ts === false) {
|
||||
return $this->ajaxOrRedirect(false, 'Invalid expiry date.');
|
||||
}
|
||||
$expiresAt = date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
$model = new SharedLinkModel();
|
||||
$token = SharedLinkModel::generateToken();
|
||||
|
||||
$id = (int) $model->insert([
|
||||
'token' => $token,
|
||||
'type' => $type,
|
||||
'resource_id' => $resourceId,
|
||||
'workspace_id' => $workspaceId,
|
||||
'password_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
'view_count' => 0,
|
||||
'is_active' => 1,
|
||||
'created_by' => $userId,
|
||||
], true);
|
||||
|
||||
$publicUrl = rtrim(base_url(), '/') . '/share/' . $token;
|
||||
|
||||
AuditLogger::log(
|
||||
'share.created',
|
||||
'shared_link',
|
||||
$id,
|
||||
null,
|
||||
[
|
||||
'type' => $type,
|
||||
'resource_id' => $resourceId,
|
||||
'has_password' => $hash !== null,
|
||||
'expires_at' => $expiresAt,
|
||||
],
|
||||
$workspaceId,
|
||||
$userId
|
||||
);
|
||||
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'token' => $token,
|
||||
'url' => $publicUrl,
|
||||
'view_count' => 0,
|
||||
'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Share link created: ' . $publicUrl);
|
||||
}
|
||||
|
||||
public function revoke(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$model = new SharedLinkModel();
|
||||
$row = $model->findOwned($id, $workspaceId);
|
||||
if (! $row) {
|
||||
return $this->ajaxOrRedirect(false, 'Link not found.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'share.revoked',
|
||||
'shared_link',
|
||||
$id,
|
||||
['is_active' => 1],
|
||||
['is_active' => 0],
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$model->update($id, ['is_active' => 0]);
|
||||
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Share link revoked.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing links for a resource (AJAX).
|
||||
*/
|
||||
public function listResource()
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$type = (string) $this->request->getGet('type');
|
||||
$resourceId = (int) $this->request->getGet('resource_id');
|
||||
if (! in_array($type, ['dashboard', 'chart'], true) || $resourceId <= 0) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid parameters.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
if ($type === 'dashboard') {
|
||||
if (! (new DashboardModel())->findForWorkspace($resourceId, $workspaceId)) {
|
||||
return $this->response->setJSON(['success' => false])->setStatusCode(404);
|
||||
}
|
||||
} elseif (! (new ChartModel())->where('workspace_id', $workspaceId)->find($resourceId)) {
|
||||
return $this->response->setJSON(['success' => false])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$rows = (new SharedLinkModel())->forResource($type, $resourceId, $workspaceId);
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = [
|
||||
'id' => (int) $r['id'],
|
||||
'url' => rtrim(base_url(), '/') . '/share/' . $r['token'],
|
||||
'view_count' => (int) ($r['view_count'] ?? 0),
|
||||
'expires_at' => $r['expires_at'],
|
||||
'has_password' => ! empty($r['password_hash']),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'links' => $out]);
|
||||
}
|
||||
|
||||
private function ajaxOrRedirect(bool $ok, string $message)
|
||||
{
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response->setJSON(['success' => $ok, 'message' => $message])->setStatusCode($ok ? 200 : 404);
|
||||
}
|
||||
|
||||
return redirect()->back()->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
309
app/Controllers/Share/PublicController.php
Normal file
309
app/Controllers/Share/PublicController.php
Normal file
@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Share;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\ChartRenderer;
|
||||
use App\Libraries\SavedQueryRunner;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DashboardModel;
|
||||
use App\Models\DashboardWidgetModel;
|
||||
use App\Models\DataSourceModel;
|
||||
use App\Models\SavedQueryModel;
|
||||
use App\Models\SharedLinkModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Unauthenticated public share views (dashboard / chart).
|
||||
*/
|
||||
class PublicController extends BaseController
|
||||
{
|
||||
private function applyEmbedHeaders(): void
|
||||
{
|
||||
$this->response->removeHeader('X-Frame-Options');
|
||||
$this->response->setHeader('Content-Security-Policy', 'frame-ancestors *');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface|string
|
||||
*/
|
||||
public function show(string $token)
|
||||
{
|
||||
$this->applyEmbedHeaders();
|
||||
$model = new SharedLinkModel();
|
||||
$row = $model->where('token', $token)->first();
|
||||
|
||||
if (! $row) {
|
||||
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Link not found']));
|
||||
}
|
||||
|
||||
if (! (int) ($row['is_active'] ?? 0)) {
|
||||
return $this->response->setStatusCode(410)->setBody(view('share/revoked', ['title' => 'Link revoked']));
|
||||
}
|
||||
|
||||
$exp = $row['expires_at'] ?? null;
|
||||
if ($exp && strtotime((string) $exp) < time()) {
|
||||
return $this->response->setStatusCode(410)->setBody(view('share/expired', ['title' => 'Link expired']));
|
||||
}
|
||||
|
||||
$session = session();
|
||||
$unlockKey = 'share_unlocked_' . $token;
|
||||
$hash = $row['password_hash'] ?? null;
|
||||
if ($hash && ! $session->get($unlockKey)) {
|
||||
$embedQ = (string) $this->request->getGet('embed') === '1' ? 'embed=1' : '';
|
||||
|
||||
return view('share/password', [
|
||||
'title' => 'Protected link | Chart-Board',
|
||||
'token' => $token,
|
||||
'redirect_q' => $embedQ,
|
||||
]);
|
||||
}
|
||||
|
||||
$linkId = (int) $row['id'];
|
||||
$vcKey = 'share_vc_' . $linkId;
|
||||
if (! $session->get($vcKey)) {
|
||||
$model->incrementViewCount($linkId);
|
||||
$session->set($vcKey, true);
|
||||
}
|
||||
|
||||
$embed = (string) $this->request->getGet('embed') === '1';
|
||||
|
||||
if ($row['type'] === 'chart') {
|
||||
return $this->renderSharedChart($row, $embed);
|
||||
}
|
||||
|
||||
return $this->renderSharedDashboard($row, $embed);
|
||||
}
|
||||
|
||||
public function unlock(string $token)
|
||||
{
|
||||
$this->applyEmbedHeaders();
|
||||
$model = new SharedLinkModel();
|
||||
$row = $model->where('token', $token)->first();
|
||||
|
||||
if (! $row || ! (int) ($row['is_active'] ?? 0)) {
|
||||
return redirect()->to('/share/' . $token)->with('error', 'Invalid link.');
|
||||
}
|
||||
|
||||
$exp = $row['expires_at'] ?? null;
|
||||
if ($exp && strtotime((string) $exp) < time()) {
|
||||
return redirect()->to('/share/' . $token)->with('error', 'This link has expired.');
|
||||
}
|
||||
|
||||
$hash = $row['password_hash'] ?? null;
|
||||
if (! $hash) {
|
||||
return redirect()->to('/share/' . $token);
|
||||
}
|
||||
|
||||
$pwd = (string) $this->request->getPost('password');
|
||||
if ($pwd === '' || ! password_verify($pwd, (string) $hash)) {
|
||||
return redirect()->back()->with('error', 'Incorrect password.');
|
||||
}
|
||||
|
||||
session()->set('share_unlocked_' . $token, true);
|
||||
|
||||
$q = trim((string) $this->request->getPost('redirect_q'));
|
||||
$target = '/share/' . $token . ($q !== '' ? '?' . $q : '');
|
||||
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
public function chartData(string $token, int $chartId)
|
||||
{
|
||||
$this->applyEmbedHeaders();
|
||||
$model = new SharedLinkModel();
|
||||
$link = $model->findActiveByToken($token);
|
||||
|
||||
if (! $link) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$hash = $link['password_hash'] ?? null;
|
||||
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
if (! $this->chartAllowed($link, $chartId)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart not in share.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$workspaceId = (int) $link['workspace_id'];
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->find($chartId);
|
||||
|
||||
if (! $chart || (int) $chart['workspace_id'] !== $workspaceId) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$savedQueryId = (int) ($chart['saved_query_id'] ?? 0);
|
||||
if ($savedQueryId <= 0) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Chart has no linked query.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Query not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Data source not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$variableValues = json_decode((string) $this->request->getPost('variables_json'), true);
|
||||
if (! is_array($variableValues)) {
|
||||
$variableValues = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, $variableValues);
|
||||
$renderer = new ChartRenderer();
|
||||
$payload = $renderer->buildPayload($chart, $result['rows']);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'row_count' => count($result['rows']),
|
||||
'execution_ms' => $result['execution_ms'],
|
||||
'cache_hit' => $result['cache_hit'],
|
||||
'chart_type' => (string) $chart['chart_type'],
|
||||
],
|
||||
'payload' => $payload,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
])->setStatusCode(422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $link
|
||||
* @return string
|
||||
*/
|
||||
private function renderSharedChart(array $link, bool $embed)
|
||||
{
|
||||
$chartId = (int) $link['resource_id'];
|
||||
if (! $this->chartAllowed($link, $chartId)) {
|
||||
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
||||
}
|
||||
|
||||
$chart = (new ChartModel())->find($chartId);
|
||||
if (! $chart || (int) $chart['workspace_id'] !== (int) $link['workspace_id']) {
|
||||
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
||||
}
|
||||
|
||||
$layout = $embed ? 'layouts/embed' : 'layouts/embed';
|
||||
$token = (string) $link['token'];
|
||||
|
||||
return view('share/chart_view', [
|
||||
'title' => (string) $chart['name'] . ' | Chart-Board',
|
||||
'layout' => $layout,
|
||||
'embed' => $embed,
|
||||
'token' => $token,
|
||||
'chartId' => $chartId,
|
||||
'chart' => $chart,
|
||||
'shareUrl' => rtrim(base_url(), '/') . '/share/' . $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $link
|
||||
* @return string
|
||||
*/
|
||||
private function renderSharedDashboard(array $link, bool $embed)
|
||||
{
|
||||
$dashId = (int) $link['resource_id'];
|
||||
$dashboard = (new DashboardModel())->find($dashId);
|
||||
if (! $dashboard || (int) $dashboard['workspace_id'] !== (int) $link['workspace_id']) {
|
||||
return $this->response->setStatusCode(404)->setBody(view('share/gone', ['title' => 'Not found']));
|
||||
}
|
||||
|
||||
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
||||
$token = (string) $link['token'];
|
||||
|
||||
$dashBoot = [
|
||||
'dashboardId' => $dashId,
|
||||
'workspaceTheme'=> (string) ($dashboard['theme'] ?? 'system'),
|
||||
'refreshSec' => max(0, (int) ($dashboard['refresh_interval'] ?? 0)),
|
||||
'widgets' => $this->widgetsBootPayload($widgets),
|
||||
'charts' => [],
|
||||
'urls' => [
|
||||
'chartData' => rtrim(base_url(), '/') . '/share/' . $token . '/chart/',
|
||||
'saveLayout' => '',
|
||||
'addWidget' => '',
|
||||
'delWidget' => '',
|
||||
'savedQueryVars' => '',
|
||||
],
|
||||
'csrf' => [
|
||||
'name' => '',
|
||||
'hash' => '',
|
||||
],
|
||||
'publicShare' => true,
|
||||
];
|
||||
|
||||
return view('share/dashboard_view', [
|
||||
'title' => (string) $dashboard['name'] . ' | Chart-Board',
|
||||
'layout' => $embed ? 'layouts/embed' : 'layouts/embed',
|
||||
'embed' => $embed,
|
||||
'dashboard' => $dashboard,
|
||||
'widgets' => $widgets,
|
||||
'dashBoot' => $dashBoot,
|
||||
'shareUrl' => rtrim(base_url(), '/') . '/share/' . $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $link
|
||||
*/
|
||||
private function chartAllowed(array $link, int $chartId): bool
|
||||
{
|
||||
if ($link['type'] === 'chart') {
|
||||
return (int) $link['resource_id'] === $chartId;
|
||||
}
|
||||
|
||||
$w = (new DashboardWidgetModel())
|
||||
->where('dashboard_id', (int) $link['resource_id'])
|
||||
->where('chart_id', $chartId)
|
||||
->where('widget_type', 'chart')
|
||||
->first();
|
||||
|
||||
return $w !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $widgets
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function widgetsBootPayload(array $widgets): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($widgets as $w) {
|
||||
$cfg = null;
|
||||
if (! empty($w['widget_config'])) {
|
||||
$d = json_decode((string) $w['widget_config'], true);
|
||||
$cfg = is_array($d) ? $d : null;
|
||||
}
|
||||
$out[] = [
|
||||
'id' => (int) $w['id'],
|
||||
'chart_id' => isset($w['chart_id']) ? (int) $w['chart_id'] : null,
|
||||
'widget_type' => (string) ($w['widget_type'] ?? 'chart'),
|
||||
'title' => $w['title'] !== null && $w['title'] !== '' ? (string) $w['title'] : null,
|
||||
'grid_x' => (int) ($w['grid_x'] ?? 0),
|
||||
'grid_y' => (int) ($w['grid_y'] ?? 0),
|
||||
'grid_w' => (int) ($w['grid_w'] ?? 4),
|
||||
'grid_h' => (int) ($w['grid_h'] ?? 3),
|
||||
'content' => $w['content'] !== null ? (string) $w['content'] : null,
|
||||
'widget_config' => $cfg,
|
||||
'chart_name' => isset($w['chart_name']) ? (string) $w['chart_name'] : null,
|
||||
'chart_type' => isset($w['chart_type']) ? (string) $w['chart_type'] : null,
|
||||
'saved_query_id' => isset($w['saved_query_id']) ? (int) $w['saved_query_id'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
284
app/Controllers/Workspace/WorkspaceController.php
Normal file
284
app/Controllers/Workspace/WorkspaceController.php
Normal file
@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Workspace;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\SettingsModel;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
use App\Models\WorkspaceModel;
|
||||
|
||||
class WorkspaceController extends BaseController
|
||||
{
|
||||
private function ensureMembership(int $workspaceId): bool
|
||||
{
|
||||
$member = (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $this->session->get('user_id'))
|
||||
->first();
|
||||
|
||||
return $member !== null;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$workspaceModel = new WorkspaceModel();
|
||||
$workspaces = $workspaceModel->forUser($userId);
|
||||
$workspaceIds = array_map(static fn($w) => (int) $w['id'], $workspaces);
|
||||
|
||||
$stats = [
|
||||
'totalWorkspaces' => count($workspaces),
|
||||
'totalMembers' => 0,
|
||||
'totalSources' => 0,
|
||||
'totalDashboards' => 0,
|
||||
];
|
||||
|
||||
if ($workspaceIds !== []) {
|
||||
$db = \Config\Database::connect();
|
||||
$stats['totalMembers'] = (int) $db->table('workspace_members')
|
||||
->select('COUNT(DISTINCT user_id) AS count', false)
|
||||
->whereIn('workspace_id', $workspaceIds)
|
||||
->get()
|
||||
->getRow('count');
|
||||
|
||||
$stats['totalSources'] = (int) $db->table('data_sources')
|
||||
->whereIn('workspace_id', $workspaceIds)
|
||||
->countAllResults();
|
||||
|
||||
$stats['totalDashboards'] = (int) $db->table('dashboards')
|
||||
->whereIn('workspace_id', $workspaceIds)
|
||||
->countAllResults();
|
||||
}
|
||||
|
||||
return view('workspace/index', [
|
||||
'title' => 'Workspaces | Chart-Board',
|
||||
'workspaces' => $workspaces,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('workspace/create', ['title' => 'Create Workspace | Chart-Board']);
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'description' => 'permit_empty|max_length[1000]',
|
||||
'timezone' => 'required|max_length[80]',
|
||||
'default_refresh' => 'required|in_list[60,300,900,3600]',
|
||||
'logo' => 'permit_empty|uploaded[logo]|max_size[logo,512]|is_image[logo]|mime_in[logo,image/jpg,image/jpeg,image/png,image/webp,image/svg+xml]',
|
||||
];
|
||||
|
||||
$hasLogo = $this->request->getFile('logo') && $this->request->getFile('logo')->isValid();
|
||||
if (! $hasLogo) {
|
||||
unset($rules['logo']);
|
||||
}
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$workspaceModel = new WorkspaceModel();
|
||||
$ownerId = (int) $this->session->get('user_id');
|
||||
|
||||
$maxWs = (new SettingsModel())->getInt(null, 'max_workspaces', 10);
|
||||
$existingCount = count($workspaceModel->forUser($ownerId));
|
||||
if ($existingCount >= $maxWs) {
|
||||
return redirect()->back()->withInput()->with('error', 'You have reached the maximum number of workspaces allowed (' . $maxWs . ').');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'slug' => (string) $this->request->getPost('slug'),
|
||||
'description' => strip_tags((string) $this->request->getPost('description')),
|
||||
'timezone' => (string) $this->request->getPost('timezone'),
|
||||
'default_refresh' => (int) $this->request->getPost('default_refresh'),
|
||||
'owner_id' => $ownerId,
|
||||
'is_active' => 1,
|
||||
];
|
||||
|
||||
if ($hasLogo) {
|
||||
$logo = $this->request->getFile('logo');
|
||||
$logoName = $logo->getRandomName();
|
||||
$logo->move(WRITEPATH . 'uploads/workspaces', $logoName);
|
||||
$data['logo'] = 'writable/uploads/workspaces/' . $logoName;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
$workspaceIdRaw = $workspaceModel->insert($data, true);
|
||||
$workspaceId = $workspaceIdRaw ? (int) $workspaceIdRaw : 0;
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
$db->transRollback();
|
||||
$errors = $workspaceModel->errors();
|
||||
$message = $errors !== [] ? implode(' ', array_values($errors)) : 'Unable to create workspace.';
|
||||
return redirect()->back()->withInput()->with('error', $message);
|
||||
}
|
||||
|
||||
$memberInsert = (new WorkspaceMemberModel())->insert([
|
||||
'workspace_id' => $workspaceId,
|
||||
'user_id' => $ownerId,
|
||||
'role' => 'admin',
|
||||
'joined_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if (! $memberInsert) {
|
||||
$db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', 'Workspace created but failed to assign owner membership.');
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
if (! $db->transStatus()) {
|
||||
return redirect()->back()->withInput()->with('error', 'Database transaction failed while creating workspace.');
|
||||
}
|
||||
|
||||
$this->session->set('active_workspace_id', $workspaceId);
|
||||
|
||||
$createdRow = $workspaceModel->find($workspaceId);
|
||||
AuditLogger::log(
|
||||
'workspace.created',
|
||||
'workspace',
|
||||
$workspaceId,
|
||||
null,
|
||||
[
|
||||
'name' => $createdRow['name'] ?? $data['name'],
|
||||
'slug' => $createdRow['slug'] ?? null,
|
||||
],
|
||||
$workspaceId,
|
||||
$ownerId
|
||||
);
|
||||
|
||||
return redirect()->to('/workspace')->with('success', 'Workspace created.');
|
||||
}
|
||||
|
||||
public function settings(int $id)
|
||||
{
|
||||
if (! $this->ensureMembership($id)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$workspace = (new WorkspaceModel())->find($id);
|
||||
if (! $workspace) {
|
||||
return redirect()->to('/workspace')->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
return view('workspace/settings', [
|
||||
'title' => 'Workspace Settings | Chart-Board',
|
||||
'workspace' => $workspace,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
{
|
||||
if (! $this->ensureMembership($id)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]|max_length[150]',
|
||||
'description' => 'permit_empty|max_length[1000]',
|
||||
'timezone' => 'required|max_length[80]',
|
||||
'default_refresh' => 'required|in_list[60,300,900,3600]',
|
||||
'is_active' => 'required|in_list[0,1]',
|
||||
];
|
||||
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
$workspaceModel = new WorkspaceModel();
|
||||
$workspace = $workspaceModel->find($id);
|
||||
if (! $workspace) {
|
||||
return redirect()->to('/workspace')->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
$oldSnap = [
|
||||
'name' => $workspace['name'] ?? null,
|
||||
'timezone' => $workspace['timezone'] ?? null,
|
||||
'default_refresh' => $workspace['default_refresh'] ?? null,
|
||||
'is_active' => $workspace['is_active'] ?? null,
|
||||
];
|
||||
$workspaceModel->update($id, [
|
||||
'id' => $id,
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'slug' => (string) $this->request->getPost('slug'),
|
||||
'description' => strip_tags((string) $this->request->getPost('description')),
|
||||
'timezone' => (string) $this->request->getPost('timezone'),
|
||||
'default_refresh' => (int) $this->request->getPost('default_refresh'),
|
||||
'is_active' => (int) $this->request->getPost('is_active'),
|
||||
]);
|
||||
|
||||
AuditLogger::log(
|
||||
'workspace.updated',
|
||||
'workspace',
|
||||
$id,
|
||||
$oldSnap,
|
||||
[
|
||||
'name' => strip_tags((string) $this->request->getPost('name')),
|
||||
'timezone' => (string) $this->request->getPost('timezone'),
|
||||
'default_refresh' => (int) $this->request->getPost('default_refresh'),
|
||||
'is_active' => (int) $this->request->getPost('is_active'),
|
||||
],
|
||||
$id,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->to('/workspace/settings/' . $id)->with('success', 'Workspace updated.');
|
||||
}
|
||||
|
||||
public function switch(int $id)
|
||||
{
|
||||
$userId = (int) $this->session->get('user_id');
|
||||
$member = (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $member) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$this->session->set('active_workspace_id', $id);
|
||||
return redirect()->back()->with('success', 'Workspace switched.');
|
||||
}
|
||||
|
||||
public function delete(int $id)
|
||||
{
|
||||
if (! $this->ensureMembership($id)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$workspaceModel = new WorkspaceModel();
|
||||
$workspace = $workspaceModel->find($id);
|
||||
if (! $workspace) {
|
||||
return redirect()->to('/workspace')->with('error', 'Workspace not found.');
|
||||
}
|
||||
|
||||
if ((int) $workspace['owner_id'] !== (int) $this->session->get('user_id')) {
|
||||
return redirect()->to('/workspace')->with('error', 'Only workspace owner can delete.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'workspace.deleted',
|
||||
'workspace',
|
||||
$id,
|
||||
['name' => $workspace['name'] ?? null],
|
||||
null,
|
||||
$id,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$workspaceModel->delete($id);
|
||||
if ((int) $this->session->get('active_workspace_id') === $id) {
|
||||
$this->session->remove('active_workspace_id');
|
||||
}
|
||||
|
||||
return redirect()->to('/workspace')->with('success', 'Workspace deleted.');
|
||||
}
|
||||
}
|
||||
150
app/Controllers/Workspace/WorkspaceInvitationController.php
Normal file
150
app/Controllers/Workspace/WorkspaceInvitationController.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Workspace;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\WorkspaceInvitationModel;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
|
||||
class WorkspaceInvitationController extends BaseController
|
||||
{
|
||||
private function hasAccess(int $workspaceId): bool
|
||||
{
|
||||
return (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $this->session->get('user_id'))
|
||||
->first() !== null;
|
||||
}
|
||||
|
||||
public function index(int $workspaceId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$invites = (new WorkspaceInvitationModel())->getPendingByWorkspace($workspaceId);
|
||||
return view('workspace/invite', [
|
||||
'title' => 'Workspace Invitations | Chart-Board',
|
||||
'workspaceId' => $workspaceId,
|
||||
'invites' => $invites,
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(int $workspaceId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email|max_length[255]',
|
||||
'role' => 'required|in_list[admin,editor,viewer]',
|
||||
];
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Invalid invite data.');
|
||||
}
|
||||
|
||||
$email = strtolower((string) $this->request->getPost('email'));
|
||||
$role = (string) $this->request->getPost('role');
|
||||
$memberModel = new WorkspaceMemberModel();
|
||||
|
||||
$existingUser = (new UserModel())->where('email', $email)->first();
|
||||
if ($existingUser) {
|
||||
$alreadyMember = $memberModel->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $existingUser['id'])
|
||||
->first();
|
||||
if ($alreadyMember) {
|
||||
return redirect()->back()->with('error', 'User is already a workspace member.');
|
||||
}
|
||||
}
|
||||
|
||||
$invModel = new WorkspaceInvitationModel();
|
||||
$duplicatePending = $invModel->where('workspace_id', $workspaceId)
|
||||
->where('email', $email)
|
||||
->where('accepted', 0)
|
||||
->first();
|
||||
if ($duplicatePending) {
|
||||
return redirect()->back()->with('error', 'A pending invite already exists.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$invModel->insert([
|
||||
'workspace_id' => $workspaceId,
|
||||
'invited_by' => (int) $this->session->get('user_id'),
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'token' => $token,
|
||||
'accepted' => 0,
|
||||
'expires_at' => date('Y-m-d H:i:s', strtotime('+48 hours')),
|
||||
]);
|
||||
|
||||
$inviteLink = base_url('invite/' . $token);
|
||||
$mail = service('email');
|
||||
$mail->setTo($email);
|
||||
$mail->setSubject('Workspace Invitation');
|
||||
$mail->setMessage('You are invited to join a workspace. Accept invitation: <a href="' . esc($inviteLink, 'attr') . '">' . esc($inviteLink) . '</a>');
|
||||
$mail->send();
|
||||
|
||||
return redirect()->back()->with('success', 'Invitation sent.');
|
||||
}
|
||||
|
||||
public function accept(string $token)
|
||||
{
|
||||
$invModel = new WorkspaceInvitationModel();
|
||||
$invite = $invModel->where('token', $token)->first();
|
||||
|
||||
if (! $invite) {
|
||||
return redirect()->to('/login')->with('error', 'Invalid invitation.');
|
||||
}
|
||||
if ((int) $invite['accepted'] === 1) {
|
||||
return redirect()->to('/login')->with('error', 'Invitation already accepted.');
|
||||
}
|
||||
if (strtotime((string) $invite['expires_at']) < time()) {
|
||||
return redirect()->to('/login')->with('error', 'Invitation expired.');
|
||||
}
|
||||
|
||||
$user = (new UserModel())->where('email', $invite['email'])->first();
|
||||
if (! $user) {
|
||||
return redirect()->to('/register')->with('error', 'Please register with invited email first.');
|
||||
}
|
||||
|
||||
$memberModel = new WorkspaceMemberModel();
|
||||
$exists = $memberModel->where('workspace_id', (int) $invite['workspace_id'])
|
||||
->where('user_id', (int) $user['id'])
|
||||
->first();
|
||||
if (! $exists) {
|
||||
$memberModel->insert([
|
||||
'workspace_id' => (int) $invite['workspace_id'],
|
||||
'user_id' => (int) $user['id'],
|
||||
'role' => (string) $invite['role'],
|
||||
'invited_by' => (int) $invite['invited_by'],
|
||||
'joined_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
$invModel->update((int) $invite['id'], ['accepted' => 1]);
|
||||
$this->session->set('active_workspace_id', (int) $invite['workspace_id']);
|
||||
|
||||
if (! $this->session->get('user_id')) {
|
||||
return redirect()->to('/login')->with('success', 'Invitation accepted. Please login.');
|
||||
}
|
||||
return redirect()->to('/workspace')->with('success', 'Invitation accepted.');
|
||||
}
|
||||
|
||||
public function cancel(int $workspaceId, int $inviteId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$model = new WorkspaceInvitationModel();
|
||||
$invite = $model->where('id', $inviteId)->where('workspace_id', $workspaceId)->first();
|
||||
if (! $invite) {
|
||||
return redirect()->back()->with('error', 'Invitation not found.');
|
||||
}
|
||||
|
||||
$model->delete($inviteId);
|
||||
return redirect()->back()->with('success', 'Invitation canceled.');
|
||||
}
|
||||
}
|
||||
94
app/Controllers/Workspace/WorkspaceMemberController.php
Normal file
94
app/Controllers/Workspace/WorkspaceMemberController.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Workspace;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\AuditLogger;
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
|
||||
class WorkspaceMemberController extends BaseController
|
||||
{
|
||||
private function hasAccess(int $workspaceId): bool
|
||||
{
|
||||
return (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('user_id', (int) $this->session->get('user_id'))
|
||||
->first() !== null;
|
||||
}
|
||||
|
||||
public function index(int $workspaceId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$memberModel = new WorkspaceMemberModel();
|
||||
$members = $memberModel->getByWorkspace($workspaceId);
|
||||
|
||||
return view('workspace/members', [
|
||||
'title' => 'Workspace Members | Chart-Board',
|
||||
'workspaceId' => $workspaceId,
|
||||
'members' => $members,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRole(int $workspaceId, int $memberId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$rules = ['role' => 'required|in_list[admin,editor,viewer]'];
|
||||
if (! $this->validate($rules)) {
|
||||
return redirect()->back()->with('error', 'Invalid role selection.');
|
||||
}
|
||||
|
||||
$model = new WorkspaceMemberModel();
|
||||
$member = $model->where('id', $memberId)->where('workspace_id', $workspaceId)->first();
|
||||
if (! $member) {
|
||||
return redirect()->back()->with('error', 'Member not found.');
|
||||
}
|
||||
|
||||
$oldRole = (string) ($member['role'] ?? '');
|
||||
$newRole = (string) $this->request->getPost('role');
|
||||
$model->update($memberId, ['role' => $newRole]);
|
||||
|
||||
AuditLogger::log(
|
||||
'workspace_member.role_changed',
|
||||
'workspace_member',
|
||||
$memberId,
|
||||
['user_id' => (int) ($member['user_id'] ?? 0), 'role' => $oldRole],
|
||||
['user_id' => (int) ($member['user_id'] ?? 0), 'role' => $newRole],
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', 'Member role updated.');
|
||||
}
|
||||
|
||||
public function remove(int $workspaceId, int $memberId)
|
||||
{
|
||||
if (! $this->hasAccess($workspaceId)) {
|
||||
return redirect()->to('/workspace')->with('error', 'Access denied for this workspace.');
|
||||
}
|
||||
|
||||
$model = new WorkspaceMemberModel();
|
||||
$member = $model->where('id', $memberId)->where('workspace_id', $workspaceId)->first();
|
||||
if (! $member) {
|
||||
return redirect()->back()->with('error', 'Member not found.');
|
||||
}
|
||||
|
||||
AuditLogger::log(
|
||||
'workspace_member.removed',
|
||||
'workspace_member',
|
||||
$memberId,
|
||||
['user_id' => (int) ($member['user_id'] ?? 0), 'role' => $member['role'] ?? null],
|
||||
null,
|
||||
$workspaceId,
|
||||
(int) $this->session->get('user_id')
|
||||
);
|
||||
|
||||
$model->delete($memberId);
|
||||
return redirect()->back()->with('success', 'Member removed from workspace.');
|
||||
}
|
||||
}
|
||||
0
app/Database/Migrations/.gitkeep
Normal file
0
app/Database/Migrations/.gitkeep
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateUsersTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 10,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
],
|
||||
'email' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
],
|
||||
'password' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
],
|
||||
'avatar' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 500,
|
||||
'null' => true,
|
||||
],
|
||||
'role' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['superadmin', 'user'],
|
||||
'default' => 'user',
|
||||
],
|
||||
'email_verified' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'verify_token' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
'reset_token' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
'reset_token_expiry' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'api_token' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
'is_active' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 1,
|
||||
],
|
||||
'last_login_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'deleted_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey('email');
|
||||
$this->forge->addUniqueKey('api_token');
|
||||
$this->forge->createTable('users', true);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('users', true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Extends charts.chart_type ENUM (additive; existing rows unchanged).
|
||||
*/
|
||||
class ExtendChartsChartTypeEnum extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->db->query("ALTER TABLE `charts` MODIFY `chart_type` ENUM(
|
||||
'bar','line','area','pie','donut',
|
||||
'scatter','table','kpi_card','funnel',
|
||||
'gauge','heatmap','combo',
|
||||
'spline','stepline','radar','bubble','polar_area'
|
||||
) NOT NULL");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Shrinking ENUM can fail if any row uses the new values; leave schema extended.
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddThemePreferenceToUsers extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->forge->addColumn('users', [
|
||||
'theme_preference' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'default' => 'light',
|
||||
'null' => false,
|
||||
'after' => 'is_active',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropColumn('users', 'theme_preference');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Doubles grid x,y,w,h so layout size stays the same when switching to
|
||||
* 24 columns and half cellHeight (finer resize steps on the dashboard).
|
||||
*/
|
||||
class DashboardWidgetsFinerGrid extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->db->query('UPDATE `dashboard_widgets` SET
|
||||
`grid_x` = `grid_x` * 2,
|
||||
`grid_y` = `grid_y` * 2,
|
||||
`grid_w` = `grid_w` * 2,
|
||||
`grid_h` = `grid_h` * 2');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->db->query('UPDATE `dashboard_widgets` SET
|
||||
`grid_x` = FLOOR(`grid_x` / 2),
|
||||
`grid_y` = FLOOR(`grid_y` / 2),
|
||||
`grid_w` = GREATEST(1, FLOOR(`grid_w` / 2)),
|
||||
`grid_h` = GREATEST(1, FLOOR(`grid_h` / 2))');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Undoes DashboardWidgetsFinerGrid: restore 12-column coordinates in DB after reverting GridStack to 12 cols.
|
||||
*/
|
||||
class RevertDashboardWidgetsFinerGrid extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->db->query('UPDATE `dashboard_widgets` SET
|
||||
`grid_x` = FLOOR(`grid_x` / 2),
|
||||
`grid_y` = FLOOR(`grid_y` / 2),
|
||||
`grid_w` = GREATEST(1, FLOOR(`grid_w` / 2)),
|
||||
`grid_h` = GREATEST(1, FLOOR(`grid_h` / 2))');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->db->query('UPDATE `dashboard_widgets` SET
|
||||
`grid_x` = `grid_x` * 2,
|
||||
`grid_y` = `grid_y` * 2,
|
||||
`grid_w` = `grid_w` * 2,
|
||||
`grid_h` = `grid_h` * 2');
|
||||
}
|
||||
}
|
||||
0
app/Database/Seeds/.gitkeep
Normal file
0
app/Database/Seeds/.gitkeep
Normal file
74
app/Database/Seeds/InitialSeeder.php
Normal file
74
app/Database/Seeds/InitialSeeder.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Seeds;
|
||||
|
||||
use CodeIgniter\Database\Seeder;
|
||||
|
||||
class InitialSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$adminEmail = 'admin@chartboard.local';
|
||||
$existing = $db->table('users')->where('email', $adminEmail)->get()->getRowArray();
|
||||
|
||||
if (! $existing) {
|
||||
$db->table('users')->insert([
|
||||
'name' => 'Super Admin',
|
||||
'email' => $adminEmail,
|
||||
'password' => password_hash('Admin@1234', PASSWORD_DEFAULT),
|
||||
'role' => 'superadmin',
|
||||
'email_verified' => 1,
|
||||
'is_active' => 1,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
$admin = $db->table('users')->where('email', $adminEmail)->get()->getRowArray();
|
||||
if (! $admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workspaceName = 'Default Workspace';
|
||||
$workspaceSlug = 'default-workspace';
|
||||
|
||||
$workspace = $db->table('workspaces')->where('slug', $workspaceSlug)->get()->getRowArray();
|
||||
if (! $workspace) {
|
||||
$db->table('workspaces')->insert([
|
||||
'name' => $workspaceName,
|
||||
'slug' => $workspaceSlug,
|
||||
'description' => 'Default workspace created by seeder',
|
||||
'timezone' => 'Asia/Kolkata',
|
||||
'default_refresh' => 300,
|
||||
'owner_id' => (int) $admin['id'],
|
||||
'is_active' => 1,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$workspace = $db->table('workspaces')->where('slug', $workspaceSlug)->get()->getRowArray();
|
||||
}
|
||||
|
||||
if (! $workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$membership = $db->table('workspace_members')
|
||||
->where('workspace_id', (int) $workspace['id'])
|
||||
->where('user_id', (int) $admin['id'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (! $membership) {
|
||||
$db->table('workspace_members')->insert([
|
||||
'workspace_id' => (int) $workspace['id'],
|
||||
'user_id' => (int) $admin['id'],
|
||||
'role' => 'admin',
|
||||
'joined_at' => date('Y-m-d H:i:s'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
0
app/Filters/.gitkeep
Normal file
0
app/Filters/.gitkeep
Normal file
68
app/Filters/ActiveWorkspaceFilter.php
Normal file
68
app/Filters/ActiveWorkspaceFilter.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class ActiveWorkspaceFilter implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* Do whatever processing this filter needs to do.
|
||||
* By default it should not return anything during
|
||||
* normal execution. However, when an abnormal state
|
||||
* is found, it should return an instance of
|
||||
* CodeIgniter\HTTP\Response. If it does, script
|
||||
* execution will end and that Response will be
|
||||
* sent back to the client, allowing for error pages,
|
||||
* redirects, etc.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param array|null $arguments
|
||||
*
|
||||
* @return RequestInterface|ResponseInterface|string|void
|
||||
*/
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$session = session();
|
||||
$userId = (int) $session->get('user_id');
|
||||
$activeWorkspaceId = (int) $session->get('active_workspace_id');
|
||||
|
||||
if ($userId <= 0) {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
if ($activeWorkspaceId <= 0) {
|
||||
return redirect()->to('/workspace')->with('error', 'Please select a workspace first.');
|
||||
}
|
||||
|
||||
$memberModel = new WorkspaceMemberModel();
|
||||
$membership = $memberModel->where('workspace_id', $activeWorkspaceId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $membership) {
|
||||
$session->remove('active_workspace_id');
|
||||
return redirect()->to('/workspace')->with('error', 'You do not have access to the selected workspace.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows After filters to inspect and modify the response
|
||||
* object as needed. This method does not allow any way
|
||||
* to stop execution of other after filters, short of
|
||||
* throwing an Exception or Error.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param array|null $arguments
|
||||
*
|
||||
* @return ResponseInterface|void
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
35
app/Filters/ApiAuthFilter.php
Normal file
35
app/Filters/ApiAuthFilter.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class ApiAuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if ($request->getMethod() === 'options') {
|
||||
return service('response')->setStatusCode(200);
|
||||
}
|
||||
|
||||
$authHeader = (string) $request->getHeaderLine('Authorization');
|
||||
|
||||
if (! str_starts_with($authHeader, 'Bearer ') || trim(substr($authHeader, 7)) === '') {
|
||||
return service('response')
|
||||
->setStatusCode(401)
|
||||
->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized',
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
24
app/Filters/AuthFilter.php
Normal file
24
app/Filters/AuthFilter.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class AuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if (session()->get('user_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redirect()->to('/login')->with('error', 'Please login to continue.');
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
34
app/Filters/RoleFilter.php
Normal file
34
app/Filters/RoleFilter.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class RoleFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$userRole = (string) session()->get('role');
|
||||
|
||||
if ($userRole === '') {
|
||||
return redirect()->to('/login');
|
||||
}
|
||||
|
||||
if ($arguments === null || $arguments === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($userRole, $arguments, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redirect()->to('/')->with('error', 'You are not allowed to access this page.');
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
44
app/Filters/WorkspaceAdminFilter.php
Normal file
44
app/Filters/WorkspaceAdminFilter.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\WorkspaceMemberModel;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Allows superadmin or workspace members with role "admin" on the active workspace.
|
||||
*/
|
||||
class WorkspaceAdminFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$session = session();
|
||||
if ((string) $session->get('role') === 'superadmin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$wid = (int) $session->get('active_workspace_id');
|
||||
$uid = (int) $session->get('user_id');
|
||||
if ($wid <= 0 || $uid <= 0) {
|
||||
return redirect()->to('/workspace')->with('error', 'Select a workspace first.');
|
||||
}
|
||||
|
||||
$member = (new WorkspaceMemberModel())
|
||||
->where('workspace_id', $wid)
|
||||
->where('user_id', $uid)
|
||||
->first();
|
||||
|
||||
if (! $member || (string) ($member['role'] ?? '') !== 'admin') {
|
||||
return redirect()->to('/dashboard')->with('error', 'Only workspace administrators can access this section.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
0
app/Helpers/.gitkeep
Normal file
0
app/Helpers/.gitkeep
Normal file
0
app/Language/.gitkeep
Normal file
0
app/Language/.gitkeep
Normal file
4
app/Language/en/Validation.php
Normal file
4
app/Language/en/Validation.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php
|
||||
|
||||
// override core en language system validation or define your own en language validation message
|
||||
return [];
|
||||
0
app/Libraries/.gitkeep
Normal file
0
app/Libraries/.gitkeep
Normal file
306
app/Libraries/AlertEngine.php
Normal file
306
app/Libraries/AlertEngine.php
Normal file
@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AlertHistoryModel;
|
||||
use App\Models\AlertModel;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DataSourceModel;
|
||||
use App\Models\SavedQueryModel;
|
||||
use Config\Email as EmailConfig;
|
||||
use Config\Services;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Evaluates alert rules against live chart query data and sends notifications.
|
||||
*/
|
||||
class AlertEngine
|
||||
{
|
||||
private const DUPLICATE_WINDOW_SEC = 300;
|
||||
|
||||
public function runAll(): int
|
||||
{
|
||||
$alerts = (new AlertModel())->allActiveForEngine();
|
||||
$processed = 0;
|
||||
foreach ($alerts as $row) {
|
||||
try {
|
||||
$this->evaluateRow($row);
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
log_message('error', 'AlertEngine: alert #' . ($row['id'] ?? '?') . ' ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $alert
|
||||
*/
|
||||
public function evaluateRow(array $alert): void
|
||||
{
|
||||
$id = (int) ($alert['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$mutedUntil = $alert['is_muted_until'] ?? null;
|
||||
if ($mutedUntil && strtotime((string) $mutedUntil) > $now) {
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$intervalMin = max(1, (int) ($alert['check_interval'] ?? 15));
|
||||
$lastChecked = $alert['last_checked_at'] ?? null;
|
||||
if ($lastChecked && (strtotime((string) $lastChecked) + $intervalMin * 60) > $now) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workspaceId = (int) $alert['workspace_id'];
|
||||
$chartId = (int) $alert['chart_id'];
|
||||
$chartModel = new ChartModel();
|
||||
$chart = $chartModel->find($chartId);
|
||||
|
||||
if (! $chart || (int) $chart['workspace_id'] !== $workspaceId) {
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$savedQueryId = (int) ($chart['saved_query_id'] ?? 0);
|
||||
if ($savedQueryId <= 0) {
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$savedQuery = (new SavedQueryModel())->where('workspace_id', $workspaceId)->find($savedQueryId);
|
||||
if (! $savedQuery) {
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dataSource = (new DataSourceModel())->where('workspace_id', $workspaceId)->find((int) $savedQuery['data_source_id']);
|
||||
if (! $dataSource) {
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$metricField = trim((string) ($alert['metric_field'] ?? ''));
|
||||
$threshold = (float) ($alert['threshold'] ?? 0);
|
||||
$condition = (string) ($alert['condition'] ?? 'gt');
|
||||
|
||||
try {
|
||||
$runner = new SavedQueryRunner();
|
||||
$result = $runner->run($workspaceId, $dataSource, $savedQuery, []);
|
||||
$rows = $result['rows'];
|
||||
} catch (Throwable $e) {
|
||||
(new AlertHistoryModel())->logEntry($id, 0.0, 'failed', null, $e->getMessage());
|
||||
(new AlertModel())->update($id, ['last_checked_at' => date('Y-m-d H:i:s')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $this->extractMetricValue($rows, $metricField);
|
||||
$checkedAt = date('Y-m-d H:i:s');
|
||||
(new AlertModel())->update($id, ['last_checked_at' => $checkedAt]);
|
||||
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->conditionMet($condition, $value, $threshold)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastTriggered = $alert['last_triggered_at'] ?? null;
|
||||
if ($lastTriggered && (strtotime((string) $lastTriggered) + self::DUPLICATE_WINDOW_SEC) > $now) {
|
||||
return;
|
||||
}
|
||||
|
||||
$channels = [];
|
||||
if (! empty($alert['notify_email'])) {
|
||||
$channels[] = 'email';
|
||||
}
|
||||
if (! empty($alert['notify_slack'])) {
|
||||
$channels[] = 'slack';
|
||||
}
|
||||
|
||||
if ($channels === []) {
|
||||
(new AlertHistoryModel())->logEntry($id, $value, 'failed', [], 'No notification channels enabled.');
|
||||
(new AlertModel())->update($id, ['last_triggered_at' => $checkedAt]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$chartName = (string) ($chart['name'] ?? 'Chart');
|
||||
$alertName = (string) ($alert['name'] ?? 'Alert');
|
||||
$message = sprintf(
|
||||
'[Chart-Board] Alert "%s" on chart "%s": %s %s %s (value=%s)',
|
||||
$alertName,
|
||||
$chartName,
|
||||
$metricField,
|
||||
$condition,
|
||||
$threshold,
|
||||
$value
|
||||
);
|
||||
|
||||
$errors = [];
|
||||
$sent = [];
|
||||
|
||||
if (in_array('email', $channels, true)) {
|
||||
$ok = $this->sendEmail($alert, $message, $errors);
|
||||
if ($ok) {
|
||||
$sent[] = 'email';
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('slack', $channels, true)) {
|
||||
$ok = $this->sendSlack($alert, $message, $errors);
|
||||
if ($ok) {
|
||||
$sent[] = 'slack';
|
||||
}
|
||||
}
|
||||
|
||||
$status = $sent !== [] ? 'sent' : 'failed';
|
||||
$errMsg = $errors !== [] ? implode('; ', $errors) : null;
|
||||
(new AlertHistoryModel())->logEntry($id, $value, $status, $sent, $errMsg);
|
||||
(new AlertModel())->update($id, ['last_triggered_at' => $checkedAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
public function extractMetricValue(array $rows, string $metricField): ?float
|
||||
{
|
||||
if ($rows === [] || $metricField === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$first = $rows[0];
|
||||
$key = $this->resolveColumnKey($first, $metricField);
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = $first[$key];
|
||||
if (is_numeric($raw)) {
|
||||
return (float) $raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function resolveColumnKey(array $row, string $metricField): ?string
|
||||
{
|
||||
if (array_key_exists($metricField, $row)) {
|
||||
return $metricField;
|
||||
}
|
||||
$target = strtolower($metricField);
|
||||
foreach (array_keys($row) as $k) {
|
||||
if (strtolower((string) $k) === $target) {
|
||||
return (string) $k;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function conditionMet(string $condition, float $value, float $threshold): bool
|
||||
{
|
||||
return match ($condition) {
|
||||
'gt' => $value > $threshold,
|
||||
'lt' => $value < $threshold,
|
||||
'gte' => $value >= $threshold,
|
||||
'lte' => $value <= $threshold,
|
||||
'eq' => abs($value - $threshold) < 0.0000001,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $alert
|
||||
* @param list<string> $errors
|
||||
*/
|
||||
private function sendEmail(array $alert, string $message, array &$errors): bool
|
||||
{
|
||||
$raw = trim((string) ($alert['email_addresses'] ?? ''));
|
||||
if ($raw === '') {
|
||||
$errors[] = 'Email: no addresses';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = array_filter(array_map('trim', explode(',', $raw)));
|
||||
$valid = [];
|
||||
foreach ($parts as $e) {
|
||||
if (filter_var($e, FILTER_VALIDATE_EMAIL)) {
|
||||
$valid[] = $e;
|
||||
}
|
||||
}
|
||||
if ($valid === []) {
|
||||
$errors[] = 'Email: invalid addresses';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = new EmailConfig();
|
||||
$email = Services::email();
|
||||
$email->setFrom($config->fromEmail, $config->fromName);
|
||||
$email->setTo($valid[0]);
|
||||
if (count($valid) > 1) {
|
||||
$email->setBCC(array_slice($valid, 1));
|
||||
}
|
||||
$email->setSubject('Chart-Board alert triggered');
|
||||
$email->setMessage(nl2br(htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')));
|
||||
|
||||
if (! $email->send()) {
|
||||
$errors[] = 'Email: ' . $email->printDebugger();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $alert
|
||||
* @param list<string> $errors
|
||||
*/
|
||||
private function sendSlack(array $alert, string $message, array &$errors): bool
|
||||
{
|
||||
$url = trim((string) ($alert['slack_webhook'] ?? ''));
|
||||
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = 'Slack: invalid webhook';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$client = Services::curlrequest(['timeout' => 10]);
|
||||
$res = $client->post($url, [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => json_encode(['text' => $message], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$code = $res->getStatusCode();
|
||||
if ($code < 200 || $code >= 300) {
|
||||
$errors[] = 'Slack HTTP ' . $code;
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = 'Slack: ' . $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
78
app/Libraries/ApiConnector.php
Normal file
78
app/Libraries/ApiConnector.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class ApiConnector
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $headers
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function fetch(string $endpoint, array $headers = [], ?string $jsonPath = null): array
|
||||
{
|
||||
if (! filter_var($endpoint, FILTER_VALIDATE_URL)) {
|
||||
throw new \InvalidArgumentException('Invalid API endpoint URL.');
|
||||
}
|
||||
|
||||
$curlHeaders = ['Accept: application/json'];
|
||||
foreach ($headers as $key => $value) {
|
||||
$k = trim((string) $key);
|
||||
if ($k === '') {
|
||||
continue;
|
||||
}
|
||||
$curlHeaders[] = $k . ': ' . (string) $value;
|
||||
}
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => $curlHeaders,
|
||||
]);
|
||||
$raw = (string) curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error !== '') {
|
||||
throw new \RuntimeException('API request failed: ' . $error);
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 400) {
|
||||
throw new \RuntimeException('API request failed with HTTP ' . $httpCode . '.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (! is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $decoded;
|
||||
if ($jsonPath !== null && trim($jsonPath) !== '') {
|
||||
foreach (explode('.', $jsonPath) as $segment) {
|
||||
$segment = trim($segment);
|
||||
if ($segment === '' || ! is_array($data) || ! array_key_exists($segment, $data)) {
|
||||
return [];
|
||||
}
|
||||
$data = $data[$segment];
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalize to list of rows.
|
||||
if ($data !== [] && array_keys($data) !== range(0, count($data) - 1)) {
|
||||
return [$data];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($data as $row) {
|
||||
if (is_array($row)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
121
app/Libraries/AuditLogger.php
Normal file
121
app/Libraries/AuditLogger.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AuditLogModel;
|
||||
use Config\Services;
|
||||
|
||||
class AuditLogger
|
||||
{
|
||||
private const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
'api_auth_value',
|
||||
'api_token',
|
||||
'password_hash',
|
||||
'public_password',
|
||||
'reset_token',
|
||||
'verify_token',
|
||||
'connection_uri',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param mixed $oldValue JSON-serializable; arrays are redacted in place for known keys
|
||||
* @param mixed $newValue JSON-serializable
|
||||
*/
|
||||
public static function log(
|
||||
string $action,
|
||||
?string $resourceType = null,
|
||||
?int $resourceId = null,
|
||||
mixed $oldValue = null,
|
||||
mixed $newValue = null,
|
||||
?int $workspaceId = null,
|
||||
?int $userId = null
|
||||
): void {
|
||||
try {
|
||||
$request = Services::request();
|
||||
$session = session();
|
||||
|
||||
if ($userId === null) {
|
||||
$uid = $session->get('user_id');
|
||||
$userId = is_numeric($uid) ? (int) $uid : null;
|
||||
}
|
||||
|
||||
$ip = method_exists($request, 'getIPAddress') ? $request->getIPAddress() : null;
|
||||
$ua = '';
|
||||
if (method_exists($request, 'getUserAgent')) {
|
||||
$ua = substr((string) $request->getUserAgent(), 0, 500);
|
||||
}
|
||||
|
||||
$oldJson = self::toJsonColumn(self::redact(self::normalize($oldValue)));
|
||||
$newJson = self::toJsonColumn(self::redact(self::normalize($newValue)));
|
||||
|
||||
(new AuditLogModel())->insert([
|
||||
'workspace_id' => $workspaceId,
|
||||
'user_id' => $userId,
|
||||
'action' => substr($action, 0, 100),
|
||||
'resource_type' => $resourceType !== null ? substr($resourceType, 0, 50) : null,
|
||||
'resource_id' => $resourceId,
|
||||
'old_value' => $oldJson,
|
||||
'new_value' => $newJson,
|
||||
'ip_address' => $ip !== '' && $ip !== null ? substr((string) $ip, 0, 45) : null,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'AuditLogger failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function normalize(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_scalar($value) || $value instanceof \Stringable) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
return is_string($json) ? json_decode($json, true) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function redact(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$key = (string) $k;
|
||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
||||
$out[$key] = $v !== null && $v !== '' ? '[redacted]' : null;
|
||||
|
||||
continue;
|
||||
}
|
||||
$out[$key] = is_array($v) ? self::redact($v) : $v;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function toJsonColumn(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
return $json === false ? '{}' : $json;
|
||||
}
|
||||
}
|
||||
767
app/Libraries/ChartRenderer.php
Normal file
767
app/Libraries/ChartRenderer.php
Normal file
@ -0,0 +1,767 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* Maps tabular query results to ApexCharts option objects (JSON-safe; client applies label formatters).
|
||||
*/
|
||||
class ChartRenderer
|
||||
{
|
||||
/** @var array<string, array<int, string>> */
|
||||
private const PALETTES = [
|
||||
'mono' => ['#111827', '#1e3a5f', '#57657a', '#93c5fd', '#e5e7eb'],
|
||||
'ocean' => ['#0c4a6e', '#0369a1', '#0ea5e9', '#38bdf8', '#bae6fd'],
|
||||
'forest' => ['#14532d', '#166534', '#22c55e', '#4ade80', '#bbf7d0'],
|
||||
'sunset' => ['#7f1d1d', '#dc2626', '#f97316', '#fbbf24', '#fef3c7'],
|
||||
'slate' => ['#0f172a', '#334155', '#64748b', '#94a3b8', '#e2e8f0'],
|
||||
'violet' => ['#4c1d95', '#6d28d9', '#8b5cf6', '#a78bfa', '#ddd6fe'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $chart Row from charts table
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPayload(array $chart, array $rows): array
|
||||
{
|
||||
$display = $this->decodeDisplayConfig($chart['display_config'] ?? null);
|
||||
$type = (string) ($chart['chart_type'] ?? 'line');
|
||||
|
||||
$hints = [
|
||||
'number_format' => (string) ($display['number_format'] ?? 'auto'),
|
||||
'decimal_places' => (int) ($display['decimal_places'] ?? 1),
|
||||
];
|
||||
|
||||
if ($type === 'table') {
|
||||
return array_merge($this->buildTablePayload($rows, $display), ['format_hints' => $hints]);
|
||||
}
|
||||
|
||||
if ($type === 'kpi_card') {
|
||||
return array_merge($this->buildKpiPayload($chart, $rows, $display), ['format_hints' => $hints]);
|
||||
}
|
||||
|
||||
$options = $this->buildApexOptions($chart, $rows, $display);
|
||||
if ($options === null) {
|
||||
return [
|
||||
'engine' => 'empty',
|
||||
'message' => 'Not enough data to render this chart.',
|
||||
'format_hints' => $hints,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'engine' => 'apex',
|
||||
'apexVersion' => 3,
|
||||
'options' => $options,
|
||||
'format_hints' => $hints,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function buildApexOptions(array $chart, array $rows, array $display): ?array
|
||||
{
|
||||
$type = (string) ($chart['chart_type'] ?? 'line');
|
||||
if ($rows === [] || $type === 'table' || $type === 'kpi_card') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$x = trim((string) ($chart['x_field'] ?? ''));
|
||||
$y = trim((string) ($chart['y_field'] ?? ''));
|
||||
$group = trim((string) ($chart['group_field'] ?? ''));
|
||||
$valueF = trim((string) ($chart['value_field'] ?? ''));
|
||||
|
||||
$colors = $this->resolveColors($display);
|
||||
$baseFont = 'Inter, system-ui, sans-serif';
|
||||
$titleText = (string) ($display['title'] ?? '');
|
||||
$subtitleText = (string) ($display['subtitle'] ?? '');
|
||||
$showGrid = array_key_exists('show_grid', $display) ? (bool) $display['show_grid'] : true;
|
||||
|
||||
$chartBase = [
|
||||
'fontFamily' => $baseFont,
|
||||
'background' => 'transparent',
|
||||
'toolbar' => ['show' => false],
|
||||
'animations' => ['enabled' => true, 'speed' => 400],
|
||||
];
|
||||
|
||||
$grid = [
|
||||
'borderColor' => 'rgba(148, 163, 184, 0.25)',
|
||||
'strokeDashArray' => 4,
|
||||
'xaxis' => ['lines' => ['show' => $showGrid]],
|
||||
'yaxis' => ['lines' => ['show' => $showGrid]],
|
||||
];
|
||||
|
||||
$legend = [
|
||||
'show' => (bool) ($display['show_legend'] ?? true),
|
||||
'position' => (string) ($display['legend_position'] ?? 'bottom'),
|
||||
'labels' => ['colors' => '#64748b'],
|
||||
'fontSize' => '12px',
|
||||
];
|
||||
|
||||
$dataLabels = [
|
||||
'enabled' => (bool) ($display['show_data_labels'] ?? false),
|
||||
'style' => ['fontSize' => '11px', 'colors' => ['#fff']],
|
||||
];
|
||||
|
||||
if ($type === 'pie' || $type === 'donut' || $type === 'polar_area') {
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
$labels = [];
|
||||
$series = [];
|
||||
foreach ($rows as $r) {
|
||||
$labels[] = $this->stringify($r[$x] ?? '');
|
||||
$series[] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
if ($series === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pieChartType = match ($type) {
|
||||
'donut' => 'donut',
|
||||
'polar_area' => 'polarArea',
|
||||
default => 'pie',
|
||||
};
|
||||
|
||||
$plotOptions = [];
|
||||
if ($type === 'pie' || $type === 'donut') {
|
||||
$plotOptions['pie'] = [
|
||||
'donut' => [
|
||||
'size' => $type === 'donut' ? '62%' : '0%',
|
||||
'labels' => ['show' => $type === 'donut', 'name' => ['fontSize' => '13px'], 'value' => ['fontSize' => '22px', 'fontWeight' => 700]],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, [
|
||||
'type' => $pieChartType,
|
||||
'height' => 360,
|
||||
]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'subtitle' => $subtitleText !== '' ? ['text' => $subtitleText, 'align' => 'left', 'style' => ['fontSize' => '12px', 'color' => '#64748b']] : ['text' => ''],
|
||||
'labels' => $labels,
|
||||
'series' => $series,
|
||||
'colors' => array_slice($colors, 0, max(count($series), 1)),
|
||||
'legend' => $legend,
|
||||
'dataLabels' => $dataLabels,
|
||||
'stroke' => ['width' => $type === 'polar_area' ? 1 : 0],
|
||||
'plotOptions' => $plotOptions,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'scatter' || $type === 'bubble') {
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
if ($type === 'bubble' && $valueF === '') {
|
||||
return null;
|
||||
}
|
||||
$data = [];
|
||||
foreach ($rows as $r) {
|
||||
$pt = [
|
||||
'x' => (float) $this->coerceNumber($r[$x] ?? null),
|
||||
'y' => (float) $this->coerceNumber($r[$y] ?? null),
|
||||
];
|
||||
if ($type === 'bubble') {
|
||||
$z = (float) $this->coerceNumber($r[$valueF] ?? 0);
|
||||
$pt['z'] = $z > 0 ? $z : 1.0;
|
||||
}
|
||||
$data[] = $pt;
|
||||
}
|
||||
|
||||
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => $scatterType, 'height' => 360, 'zoom' => ['enabled' => true, 'type' => 'xy']]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => [['name' => $y, 'data' => $data]],
|
||||
'colors' => [$colors[0]],
|
||||
'grid' => $grid,
|
||||
'xaxis' => [
|
||||
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
'axisBorder' => ['show' => false],
|
||||
'axisTicks' => ['show' => false],
|
||||
],
|
||||
'yaxis' => [
|
||||
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
],
|
||||
'markers' => ['size' => 6],
|
||||
'tooltip' => ['theme' => 'light'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'radar') {
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
$categories = [];
|
||||
foreach ($rows as $r) {
|
||||
$categories[] = $this->stringify($r[$x] ?? '');
|
||||
}
|
||||
$categories = array_values(array_unique($categories));
|
||||
|
||||
if ($group !== '') {
|
||||
$seriesMap = [];
|
||||
foreach ($rows as $r) {
|
||||
$g = $this->stringify($r[$group] ?? 'Series');
|
||||
$cat = $this->stringify($r[$x] ?? '');
|
||||
$seriesMap[$g][$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
$series = [];
|
||||
foreach ($seriesMap as $name => $byCat) {
|
||||
$data = [];
|
||||
foreach ($categories as $c) {
|
||||
$data[] = $byCat[$c] ?? 0;
|
||||
}
|
||||
$series[] = ['name' => $name, 'data' => $data];
|
||||
}
|
||||
} else {
|
||||
$byCat = [];
|
||||
foreach ($rows as $r) {
|
||||
$cat = $this->stringify($r[$x] ?? '');
|
||||
$byCat[$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
$data = [];
|
||||
foreach ($categories as $c) {
|
||||
$data[] = $byCat[$c] ?? 0;
|
||||
}
|
||||
$series = [['name' => $y, 'data' => $data]];
|
||||
}
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => 'radar', 'height' => 400]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => $series,
|
||||
'xaxis' => ['categories' => $categories],
|
||||
'stroke' => ['width' => 2],
|
||||
'markers' => ['size' => 4],
|
||||
'fill' => ['type' => 'solid', 'opacity' => 0.2],
|
||||
'yaxis' => ['show' => false],
|
||||
'dataLabels' => $dataLabels,
|
||||
'colors' => array_slice($colors, 0, max(count($series), 3)),
|
||||
'legend' => $legend,
|
||||
'tooltip' => ['theme' => 'light'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'funnel') {
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
$cats = [];
|
||||
$vals = [];
|
||||
foreach ($rows as $r) {
|
||||
$cats[] = $this->stringify($r[$x] ?? '');
|
||||
$vals[] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => 'bar', 'height' => 360]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => [['name' => $y, 'data' => $vals]],
|
||||
'plotOptions' => [
|
||||
'bar' => [
|
||||
'horizontal' => true,
|
||||
'barHeight' => '75%',
|
||||
'distributed' => true,
|
||||
'borderRadius' => 6,
|
||||
],
|
||||
],
|
||||
'colors' => array_slice($colors, 0, max(count($vals), 1)),
|
||||
'dataLabels' => array_merge($dataLabels, ['enabled' => true]),
|
||||
'xaxis' => ['categories' => $cats, 'labels' => ['style' => ['colors' => '#64748b']]],
|
||||
'yaxis' => ['labels' => ['style' => ['colors' => '#64748b']]],
|
||||
'legend' => ['show' => false],
|
||||
'grid' => $grid,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'gauge') {
|
||||
$col = $valueF !== '' ? $valueF : $y;
|
||||
if ($col === '') {
|
||||
return null;
|
||||
}
|
||||
$val = (float) $this->coerceNumber($rows[array_key_last($rows)][$col] ?? 0);
|
||||
$max = (float) ($display['gauge_max'] ?? 100);
|
||||
if ($max <= 0) {
|
||||
$max = 100;
|
||||
}
|
||||
$pct = min(100, max(0, ($val / $max) * 100));
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => 'radialBar', 'height' => 360]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => [round($pct, 1)],
|
||||
'labels' => [$col],
|
||||
'colors' => [$colors[0]],
|
||||
'plotOptions' => [
|
||||
'radialBar' => [
|
||||
'startAngle' => -135,
|
||||
'endAngle' => 135,
|
||||
'hollow' => ['size' => '58%'],
|
||||
'track' => ['background' => '#e2e8f0'],
|
||||
'dataLabels' => [
|
||||
'name' => ['fontSize' => '13px', 'color' => '#64748b', 'offsetY' => 72],
|
||||
'value' => [
|
||||
'fontSize' => '28px',
|
||||
'fontWeight' => 700,
|
||||
'color' => '#0f172a',
|
||||
'offsetY' => -10,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'heatmap') {
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
$seriesNameCol = $group !== '' ? $group : '__single__';
|
||||
$matrix = $this->buildHeatmapMatrix($rows, $x, $seriesNameCol, $y);
|
||||
if ($matrix === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => 'heatmap', 'height' => 380]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => $matrix['series'],
|
||||
'dataLabels' => ['enabled' => false],
|
||||
'colors' => [$colors[0]],
|
||||
'xaxis' => ['type' => 'category', 'categories' => $matrix['xCategories'], 'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']]],
|
||||
'plotOptions' => [
|
||||
'heatmap' => [
|
||||
'shadeIntensity' => 0.45,
|
||||
'radius' => 3,
|
||||
'colorScale' => [
|
||||
'ranges' => [
|
||||
['from' => $matrix['min'], 'to' => $matrix['mid'], 'color' => $colors[3]],
|
||||
['from' => $matrix['mid'], 'to' => $matrix['max'], 'color' => $colors[0]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tooltip' => ['theme' => 'light'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($x === '' || $y === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$horizontal = ! empty($display['horizontal_bar']);
|
||||
if ($type === 'spline') {
|
||||
$curve = 'smooth';
|
||||
} elseif ($type === 'stepline') {
|
||||
$curve = 'stepline';
|
||||
} else {
|
||||
$curve = 'smooth';
|
||||
if (isset($display['smooth']) && ! $display['smooth']) {
|
||||
$curve = 'straight';
|
||||
}
|
||||
if (! empty($display['stepline'])) {
|
||||
$curve = 'stepline';
|
||||
}
|
||||
}
|
||||
|
||||
$categories = [];
|
||||
foreach ($rows as $r) {
|
||||
$categories[] = $this->stringify($r[$x] ?? '');
|
||||
}
|
||||
$categories = array_values(array_unique($categories));
|
||||
|
||||
if ($group !== '') {
|
||||
$seriesMap = [];
|
||||
foreach ($rows as $r) {
|
||||
$g = $this->stringify($r[$group] ?? 'Series');
|
||||
$cat = $this->stringify($r[$x] ?? '');
|
||||
$seriesMap[$g][$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
$series = [];
|
||||
foreach ($seriesMap as $name => $byCat) {
|
||||
$data = [];
|
||||
foreach ($categories as $c) {
|
||||
$data[] = $byCat[$c] ?? 0;
|
||||
}
|
||||
$series[] = ['name' => $name, 'data' => $data];
|
||||
}
|
||||
} else {
|
||||
$byCat = [];
|
||||
foreach ($rows as $r) {
|
||||
$cat = $this->stringify($r[$x] ?? '');
|
||||
$byCat[$cat] = (float) $this->coerceNumber($r[$y] ?? 0);
|
||||
}
|
||||
$data = [];
|
||||
foreach ($categories as $c) {
|
||||
$data[] = $byCat[$c] ?? 0;
|
||||
}
|
||||
$series = [['name' => $y, 'data' => $data]];
|
||||
}
|
||||
|
||||
$stacked = ! empty($display['stacked']);
|
||||
|
||||
if ($type === 'combo') {
|
||||
$y2 = trim((string) ($display['secondary_y_field'] ?? ''));
|
||||
if ($y2 === '' || $group !== '') {
|
||||
return null;
|
||||
}
|
||||
$lineData = $this->columnValues($rows, $categories, $x, $y2, '');
|
||||
$barSeries = array_merge($series[0], ['type' => 'column']);
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, ['type' => 'line', 'height' => 380]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'stroke' => ['width' => [0, 3], 'curve' => [$curve, $curve]],
|
||||
'series' => [
|
||||
$barSeries,
|
||||
['name' => $y2, 'type' => 'line', 'data' => $lineData],
|
||||
],
|
||||
'xaxis' => [
|
||||
'categories' => $categories,
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
'axisBorder' => ['show' => false],
|
||||
'axisTicks' => ['show' => false],
|
||||
'title' => ['text' => (string) ($display['x_axis_label'] ?? '')],
|
||||
],
|
||||
'yaxis' => $this->buildYAxis($display, $y, false),
|
||||
'plotOptions' => ['bar' => ['columnWidth' => '55%', 'borderRadius' => 5]],
|
||||
'colors' => array_slice($colors, 0, 4),
|
||||
'grid' => $grid,
|
||||
'legend' => $legend,
|
||||
'fill' => ['type' => 'solid', 'opacity' => [1, 1]],
|
||||
'tooltip' => $this->sharedTooltip(),
|
||||
];
|
||||
}
|
||||
|
||||
$apexType = $type === 'bar' ? 'bar' : ($type === 'area' ? 'area' : 'line');
|
||||
// Must be an object with `type` — never [] (JSON []). Apex reads fill.type[i] in sameValueSeriesFix;
|
||||
// undefined[i] throws "Cannot read properties of undefined (reading '0')".
|
||||
$fill = ['type' => 'solid', 'opacity' => 1];
|
||||
if ($type === 'area') {
|
||||
$fill = [
|
||||
'type' => 'gradient',
|
||||
'gradient' => ['shadeIntensity' => 0.35, 'opacityFrom' => 0.45, 'opacityTo' => 0.05, 'stops' => [0, 90, 100]],
|
||||
];
|
||||
}
|
||||
|
||||
$plotBar = [
|
||||
'horizontal' => $horizontal,
|
||||
'borderRadius' => $horizontal ? 0 : 5,
|
||||
'columnWidth' => '58%',
|
||||
];
|
||||
if ($stacked && $apexType === 'bar') {
|
||||
$plotBar['stacked'] = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'chart' => array_merge($chartBase, [
|
||||
'type' => $apexType,
|
||||
'height' => 380,
|
||||
'stacked' => $stacked,
|
||||
]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'subtitle' => $subtitleText !== '' ? ['text' => $subtitleText, 'align' => 'left', 'style' => ['fontSize' => '12px', 'color' => '#64748b']] : ['text' => ''],
|
||||
'series' => $series,
|
||||
'xaxis' => [
|
||||
'categories' => $categories,
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
'axisBorder' => ['show' => false],
|
||||
'axisTicks' => ['show' => false],
|
||||
'title' => ['text' => (string) ($display['x_axis_label'] ?? '')],
|
||||
],
|
||||
'yaxis' => $this->buildYAxis($display, $y, true),
|
||||
'stroke' => ['curve' => $curve, 'width' => $type === 'bar' ? 0 : 2.5],
|
||||
'markers' => ['size' => in_array($type, ['line', 'area', 'spline', 'stepline'], true) ? 0 : 4],
|
||||
'dataLabels' => $dataLabels,
|
||||
'colors' => array_slice($colors, 0, max(count($series), 3)),
|
||||
'grid' => $grid,
|
||||
'legend' => $legend,
|
||||
'plotOptions' => ['bar' => $plotBar],
|
||||
'fill' => $fill,
|
||||
'tooltip' => $this->sharedTooltip(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar charts default tooltip.intersect=true, which conflicts with shared tooltips in ApexCharts.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sharedTooltip(): array
|
||||
{
|
||||
return [
|
||||
'shared' => true,
|
||||
'intersect' => false,
|
||||
'theme' => 'light',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildYAxis(array $display, string $yTitle, bool $includeFontSize): array
|
||||
{
|
||||
$yaxisOpt = [
|
||||
'title' => ['text' => (string) ($display['y_axis_label'] ?? $yTitle), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b']],
|
||||
];
|
||||
if ($includeFontSize) {
|
||||
$yaxisOpt['labels']['style']['fontSize'] = '11px';
|
||||
}
|
||||
if (isset($display['y_min']) && $display['y_min'] !== '' && is_numeric($display['y_min'])) {
|
||||
$yaxisOpt['min'] = (float) $display['y_min'];
|
||||
}
|
||||
if (isset($display['y_max']) && $display['y_max'] !== '' && is_numeric($display['y_max'])) {
|
||||
$yaxisOpt['max'] = (float) $display['y_max'];
|
||||
}
|
||||
|
||||
return $yaxisOpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildTablePayload(array $rows, array $display): array
|
||||
{
|
||||
$columns = $rows !== [] ? array_keys($rows[0]) : [];
|
||||
$limit = isset($display['table_preview_limit']) ? (int) $display['table_preview_limit'] : 500;
|
||||
|
||||
return [
|
||||
'engine' => 'table',
|
||||
'columns' => $columns,
|
||||
'rows' => array_slice($rows, 0, max(1, $limit)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildKpiPayload(array $chart, array $rows, array $display): array
|
||||
{
|
||||
$cols = $display['kpi_columns'] ?? null;
|
||||
if (! is_array($cols) || $cols === []) {
|
||||
$y = trim((string) ($chart['y_field'] ?? ''));
|
||||
$cols = $y !== '' ? [$y] : [];
|
||||
}
|
||||
if ($cols === [] || $rows === []) {
|
||||
return ['engine' => 'kpi', 'cards' => []];
|
||||
}
|
||||
|
||||
$row = $rows[array_key_last($rows)];
|
||||
$cards = [];
|
||||
foreach ($cols as $c) {
|
||||
$c = trim((string) $c);
|
||||
if ($c === '') {
|
||||
continue;
|
||||
}
|
||||
$raw = $row[$c] ?? null;
|
||||
$cards[] = [
|
||||
'label' => $c,
|
||||
'value' => $this->formatNumber($this->coerceNumber($raw), $display),
|
||||
'raw' => $raw,
|
||||
];
|
||||
if (count($cards) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['engine' => 'kpi', 'cards' => $cards];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{series: array<int, mixed>, xCategories: array<int, string>, min: float, max: float, mid: float}|null
|
||||
*/
|
||||
private function buildHeatmapMatrix(array $rows, string $xCol, string $seriesCol, string $valueCol): ?array
|
||||
{
|
||||
$xCats = [];
|
||||
$sCats = [];
|
||||
foreach ($rows as $r) {
|
||||
$xCats[$this->stringify($r[$xCol] ?? '')] = true;
|
||||
if ($seriesCol === '__single__') {
|
||||
$sCats['Value'] = true;
|
||||
} else {
|
||||
$sCats[$this->stringify($r[$seriesCol] ?? 'Series')] = true;
|
||||
}
|
||||
}
|
||||
$xCategories = array_keys($xCats);
|
||||
$seriesNames = array_keys($sCats);
|
||||
if ($xCategories === [] || $seriesNames === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lookup = [];
|
||||
$min = PHP_FLOAT_MAX;
|
||||
$max = -PHP_FLOAT_MAX;
|
||||
foreach ($rows as $r) {
|
||||
$xc = $this->stringify($r[$xCol] ?? '');
|
||||
$sn = $seriesCol === '__single__' ? 'Value' : $this->stringify($r[$seriesCol] ?? 'Series');
|
||||
$v = (float) $this->coerceNumber($r[$valueCol] ?? 0);
|
||||
$lookup[$sn][$xc] = $v;
|
||||
$min = min($min, $v);
|
||||
$max = max($max, $v);
|
||||
}
|
||||
if ($min === PHP_FLOAT_MAX) {
|
||||
$min = 0;
|
||||
$max = 1;
|
||||
}
|
||||
$mid = $min + ($max - $min) / 2;
|
||||
|
||||
$series = [];
|
||||
foreach ($seriesNames as $name) {
|
||||
$data = [];
|
||||
foreach ($xCategories as $xc) {
|
||||
$data[] = ['x' => $xc, 'y' => $lookup[$name][$xc] ?? 0];
|
||||
}
|
||||
$series[] = ['name' => $name, 'data' => $data];
|
||||
}
|
||||
|
||||
return ['series' => $series, 'xCategories' => $xCategories, 'min' => $min, 'max' => $max, 'mid' => $mid];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function columnValues(array $rows, array $categories, string $xCol, string $yCol, string $group): array
|
||||
{
|
||||
if ($group !== '') {
|
||||
return array_fill(0, count($categories), 0);
|
||||
}
|
||||
$byCat = [];
|
||||
foreach ($rows as $r) {
|
||||
$byCat[$this->stringify($r[$xCol] ?? '')] = (float) $this->coerceNumber($r[$yCol] ?? 0);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($categories as $c) {
|
||||
$out[] = $byCat[$c] ?? 0;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveColors(array $display): array
|
||||
{
|
||||
$mode = (string) ($display['color_mode'] ?? 'preset');
|
||||
$custom = $display['palette_colors'] ?? null;
|
||||
|
||||
$mono = self::PALETTES['mono'];
|
||||
|
||||
if ($mode === 'custom' && is_array($custom) && $custom !== []) {
|
||||
$hex = [];
|
||||
foreach ($custom as $c) {
|
||||
$s = is_scalar($c) ? trim((string) $c) : '';
|
||||
if ($s !== '' && preg_match('/^#[0-9A-Fa-f]{6}$/', $s)) {
|
||||
$hex[] = $s;
|
||||
}
|
||||
}
|
||||
if ($hex !== []) {
|
||||
return array_values($hex);
|
||||
}
|
||||
|
||||
return $mono;
|
||||
}
|
||||
|
||||
if ($mode !== 'custom' && is_array($custom) && $custom !== []) {
|
||||
$fromLegacy = array_values(array_filter(array_map('strval', $custom)));
|
||||
if ($fromLegacy !== []) {
|
||||
return $fromLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
$name = (string) ($display['palette'] ?? 'mono');
|
||||
$pal = self::PALETTES[$name] ?? $mono;
|
||||
|
||||
return $pal !== [] ? $pal : $mono;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeDisplayConfig(mixed $json): array
|
||||
{
|
||||
if ($json === null || $json === '') {
|
||||
return [];
|
||||
}
|
||||
if (is_array($json)) {
|
||||
return $json;
|
||||
}
|
||||
$d = json_decode((string) $json, true);
|
||||
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
|
||||
private function stringify(mixed $v): string
|
||||
{
|
||||
if ($v === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_scalar($v)) {
|
||||
return (string) $v;
|
||||
}
|
||||
|
||||
return json_encode($v) ?: '';
|
||||
}
|
||||
|
||||
private function coerceNumber(mixed $v): float|int
|
||||
{
|
||||
if (is_int($v) || is_float($v)) {
|
||||
return $v;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
$n = str_replace([',', ' '], '', $v);
|
||||
|
||||
return is_numeric($n) ? 0 + $n : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
*/
|
||||
private function formatNumber(float|int $n, array $display): string
|
||||
{
|
||||
$fmt = (string) ($display['number_format'] ?? 'auto');
|
||||
$dec = isset($display['decimal_places']) ? (int) $display['decimal_places'] : 1;
|
||||
|
||||
return match ($fmt) {
|
||||
'inr' => '₹' . number_format((float) $n, $dec),
|
||||
'usd' => '$' . number_format((float) $n, $dec),
|
||||
'percent' => number_format((float) $n, $dec) . '%',
|
||||
'compact' => $this->compactNumber((float) $n, $dec),
|
||||
default => number_format((float) $n, $dec),
|
||||
};
|
||||
}
|
||||
|
||||
private function compactNumber(float $n, int $dec): string
|
||||
{
|
||||
$abs = abs($n);
|
||||
if ($abs >= 1_000_000_000) {
|
||||
return round($n / 1_000_000_000, $dec) . 'B';
|
||||
}
|
||||
if ($abs >= 1_000_000) {
|
||||
return round($n / 1_000_000, $dec) . 'M';
|
||||
}
|
||||
if ($abs >= 1_000) {
|
||||
return round($n / 1_000, $dec) . 'K';
|
||||
}
|
||||
|
||||
return number_format($n, $dec);
|
||||
}
|
||||
}
|
||||
25
app/Libraries/ConnectionFactory.php
Normal file
25
app/Libraries/ConnectionFactory.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Libraries\Connectors\CsvConnector;
|
||||
use App\Libraries\Connectors\MongoDBConnector;
|
||||
use App\Libraries\Connectors\MySQLConnector;
|
||||
use App\Libraries\Connectors\PostgreSQLConnector;
|
||||
use App\Libraries\Connectors\RestApiConnector;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ConnectionFactory
|
||||
{
|
||||
public function make(string $type): object
|
||||
{
|
||||
return match ($type) {
|
||||
'mysql' => new MySQLConnector(),
|
||||
'postgresql' => new PostgreSQLConnector(),
|
||||
'mongodb' => new MongoDBConnector(),
|
||||
'rest_api' => new RestApiConnector(),
|
||||
'csv' => new CsvConnector(),
|
||||
default => throw new InvalidArgumentException('Unsupported data source type.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
app/Libraries/Connectors/CsvConnector.php
Normal file
33
app/Libraries/Connectors/CsvConnector.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Connectors;
|
||||
|
||||
class CsvConnector
|
||||
{
|
||||
public function test(array $config): array
|
||||
{
|
||||
$path = (string) ($config['csv_file_path'] ?? '');
|
||||
if ($path === '') {
|
||||
return ['success' => false, 'message' => 'CSV file path is required.'];
|
||||
}
|
||||
|
||||
if (! is_file($path)) {
|
||||
return ['success' => false, 'message' => 'CSV file not found.'];
|
||||
}
|
||||
|
||||
$delimiter = (string) ($config['csv_delimiter'] ?? ',');
|
||||
$handle = fopen($path, 'r');
|
||||
if ($handle === false) {
|
||||
return ['success' => false, 'message' => 'Unable to open CSV file.'];
|
||||
}
|
||||
|
||||
$row = fgetcsv($handle, 0, $delimiter);
|
||||
fclose($handle);
|
||||
|
||||
if ($row === false) {
|
||||
return ['success' => false, 'message' => 'CSV file appears to be empty or invalid.'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'CSV file is readable and valid.'];
|
||||
}
|
||||
}
|
||||
33
app/Libraries/Connectors/MongoDBConnector.php
Normal file
33
app/Libraries/Connectors/MongoDBConnector.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Connectors;
|
||||
|
||||
use MongoDB\Driver\Command;
|
||||
use MongoDB\Driver\Exception\Exception;
|
||||
use MongoDB\Driver\Manager;
|
||||
|
||||
class MongoDBConnector
|
||||
{
|
||||
public function test(array $config): array
|
||||
{
|
||||
if (! class_exists(Manager::class)) {
|
||||
return ['success' => false, 'message' => 'MongoDB extension is not installed.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$uri = (string) ($config['connection_uri'] ?? '');
|
||||
if ($uri === '') {
|
||||
$host = (string) ($config['host'] ?? '127.0.0.1');
|
||||
$port = (int) ($config['port'] ?? 27017);
|
||||
$uri = sprintf('mongodb://%s:%d', $host, $port);
|
||||
}
|
||||
|
||||
$manager = new Manager($uri);
|
||||
$manager->executeCommand('admin', new Command(['ping' => 1]));
|
||||
|
||||
return ['success' => true, 'message' => 'MongoDB connection successful.'];
|
||||
} catch (Exception $e) {
|
||||
return ['success' => false, 'message' => 'MongoDB connection failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Libraries/Connectors/MySQLConnector.php
Normal file
36
app/Libraries/Connectors/MySQLConnector.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Connectors;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class MySQLConnector
|
||||
{
|
||||
public function test(array $config): array
|
||||
{
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||
$config['host'] ?? '',
|
||||
(int) ($config['port'] ?? 3306),
|
||||
$config['database_name'] ?? ''
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
(string) ($config['username'] ?? ''),
|
||||
(string) ($config['password'] ?? ''),
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
]
|
||||
);
|
||||
$pdo->query('SELECT 1');
|
||||
|
||||
return ['success' => true, 'message' => 'MySQL connection successful.'];
|
||||
} catch (PDOException $e) {
|
||||
return ['success' => false, 'message' => 'MySQL connection failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Libraries/Connectors/PostgreSQLConnector.php
Normal file
36
app/Libraries/Connectors/PostgreSQLConnector.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Connectors;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class PostgreSQLConnector
|
||||
{
|
||||
public function test(array $config): array
|
||||
{
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'pgsql:host=%s;port=%d;dbname=%s',
|
||||
$config['host'] ?? '',
|
||||
(int) ($config['port'] ?? 5432),
|
||||
$config['database_name'] ?? ''
|
||||
);
|
||||
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
(string) ($config['username'] ?? ''),
|
||||
(string) ($config['password'] ?? ''),
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
]
|
||||
);
|
||||
$pdo->query('SELECT 1');
|
||||
|
||||
return ['success' => true, 'message' => 'PostgreSQL connection successful.'];
|
||||
} catch (PDOException $e) {
|
||||
return ['success' => false, 'message' => 'PostgreSQL connection failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user