mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Compare commits
23 Commits
dev
...
0b30c66307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b30c66307 | ||
|
|
d038ebc2e3 | ||
|
|
e19828868f | ||
|
|
3615d029ea | ||
|
|
bf05b5d295 | ||
|
|
3b65f482c7 | ||
|
|
7cea9e5ea4 | ||
|
|
f01e04fbad | ||
|
|
3ab93381f5 | ||
|
|
5454644a31 | ||
|
|
caf81ea4e2 | ||
|
|
f27498dc26 | ||
|
|
bb09f3d024 | ||
|
|
daa6ec8b1e | ||
|
|
43f0a742b6 | ||
|
|
02f77a15a7 | ||
|
|
e125ac34d7 | ||
|
|
fb9ff9d56b | ||
|
|
7c81586d3f | ||
|
|
3438888314 | ||
|
|
af21317040 | ||
|
|
092bb53324 | ||
|
|
6cbb6a2e3e |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -125,4 +125,9 @@ _modules/*
|
||||
/results/
|
||||
/phpunit*.xml
|
||||
.env
|
||||
env
|
||||
env
|
||||
.claude/
|
||||
.claude/*
|
||||
|
||||
# Generated docs
|
||||
/public/api-docs.html
|
||||
553
README.md
553
README.md
@@ -1 +1,552 @@
|
||||
# Todo-App-Backend
|
||||
# Todo App Backend
|
||||
|
||||
A RESTful API backend for a todo application built with **CodeIgniter 4**.
|
||||
Supports user authentication (API key + JWT), CRUD for todos/categories/projects,
|
||||
recurring tasks, activity logging, and a theme marketplace.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Authentication](#authentication)
|
||||
- [API Overview](#api-overview)
|
||||
- [Testing](#testing)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Database](#database)
|
||||
- [Development](#development)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP ^8.2
|
||||
- MySQL 8+ (or MariaDB 10.5+)
|
||||
- Composer
|
||||
- `ext-intl`, `ext-mbstring`
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone and enter the project
|
||||
cd Todo-App-Backend
|
||||
|
||||
# 2. Install dependencies
|
||||
composer install
|
||||
|
||||
# 3. Configure your environment
|
||||
cp env.example .env
|
||||
# Edit .env — set database credentials and app.baseURL
|
||||
|
||||
# 4. Run database migrations
|
||||
php spark migrate
|
||||
|
||||
# 5. (Optional) Seed sample data
|
||||
php spark db:seed SampleDataSeeder
|
||||
|
||||
# 6. Start the development server
|
||||
php spark serve
|
||||
|
||||
# The API is now available at http://localhost:8080/api/v1
|
||||
```
|
||||
|
||||
### Quick Test
|
||||
|
||||
```bash
|
||||
# Register a new user
|
||||
curl -s -X POST http://localhost:8080/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"demo@example.com","password":"password123","name":"Demo User"}'
|
||||
|
||||
# Save the returned api_key, then:
|
||||
curl -s http://localhost:8080/api/v1/todos \
|
||||
-H "X-API-Key: todo_your_key_here"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
The API is fully documented using the **OpenAPI 3.0** specification.
|
||||
|
||||
| Resource | Location |
|
||||
|----------|----------|
|
||||
| OpenAPI spec (canonical) | [`openapi/openapi.yaml`](openapi/openapi.yaml) |
|
||||
| Generated HTML docs | `public/api-docs.html` (generated, see below) |
|
||||
| Swagger/Postman import | Use `openapi/openapi.yaml` directly |
|
||||
|
||||
### Generating API Docs
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Generate HTML documentation page
|
||||
php spark generate:api-docs
|
||||
|
||||
# Validate spec only (no file written)
|
||||
php spark generate:api-docs --watch
|
||||
|
||||
# Open http://localhost:8080/api-docs.html after generating
|
||||
```
|
||||
|
||||
The generated HTML uses [Redoc](https://redocly.com/redoc) for rendering and is
|
||||
fully self-contained (the spec is embedded as a base64 data URI).
|
||||
|
||||
### Importing into Tools
|
||||
|
||||
- **Postman**: File → Import → choose `openapi/openapi.yaml`
|
||||
- **Insomnia**: Import → From File → choose `openapi/openapi.yaml`
|
||||
- **Swagger Editor**: Paste the contents of `openapi/openapi.yaml`
|
||||
- **cURL/HTTPie**: Examples are in the OpenAPI spec under each endpoint
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
The API supports two authentication methods:
|
||||
|
||||
### 1. API Key Authentication (Primary)
|
||||
|
||||
```
|
||||
X-API-Key: todo_abc123def456...
|
||||
```
|
||||
|
||||
Used by most protected endpoints. Keys are obtained on registration or can be
|
||||
created via `POST /user/api-keys`. Keys can be scoped (`read`, `write`) and
|
||||
optionally expire.
|
||||
|
||||
### 2. JWT Bearer Authentication
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1Qi...
|
||||
```
|
||||
|
||||
Available via the `/auth/jwt/*` endpoints. Tokens are valid for 1 hour and can
|
||||
be refreshed via `/auth/jwt/refresh`.
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Register** → receive API key + key prefix
|
||||
2. **Login** → receive the same or existing API key
|
||||
3. Include `X-API-Key` header on all protected requests
|
||||
4. Optionally use JWT endpoints for short-lived bearer tokens
|
||||
|
||||
---
|
||||
|
||||
## API Overview
|
||||
|
||||
All endpoints live under the `/api/v1` prefix.
|
||||
|
||||
### Public Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/auth/register` | Register a new user |
|
||||
| POST | `/auth/login` | Login and get API key |
|
||||
| POST | `/auth/api-key` | Create additional API key (legacy) |
|
||||
| POST | `/auth/jwt/register` | Register and receive JWT + API key |
|
||||
| POST | `/auth/jwt/login` | Login and receive JWT |
|
||||
| POST | `/auth/jwt/refresh` | Refresh an existing JWT |
|
||||
| GET | `/marketplace/themes` | List published themes |
|
||||
| GET | `/marketplace/themes/{id}` | Get a single theme |
|
||||
|
||||
### Protected Endpoints (API key required)
|
||||
|
||||
#### User
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/user/profile` | Get your profile |
|
||||
| PUT | `/user/profile` | Update your profile |
|
||||
| GET | `/user/api-keys` | List your API keys |
|
||||
| POST | `/user/api-keys` | Create a new API key |
|
||||
| DELETE | `/user/api-keys/{id}` | Revoke an API key |
|
||||
|
||||
#### Categories
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/categories` | List categories (paginated, sortable) |
|
||||
| POST | `/categories` | Create a category |
|
||||
| GET | `/categories/{id}` | Get a category |
|
||||
| PUT | `/categories/{id}` | Update a category |
|
||||
| DELETE | `/categories/{id}` | Delete a category |
|
||||
|
||||
#### Projects
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/projects` | List projects (paginated, sortable) |
|
||||
| POST | `/projects` | Create a project |
|
||||
| GET | `/projects/{id}` | Get a project |
|
||||
| PUT | `/projects/{id}` | Update a project |
|
||||
| DELETE | `/projects/{id}` | Delete a project |
|
||||
|
||||
#### Todos
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/todos` | List todos (paginated, sortable, filterable) |
|
||||
| POST | `/todos` | Create a todo |
|
||||
| GET | `/todos/{id}` | Get a todo |
|
||||
| PUT | `/todos/{id}` | Update a todo |
|
||||
| DELETE | `/todos/{id}` | Delete a todo |
|
||||
| POST | `/todos/{id}/categories` | Link a category |
|
||||
| DELETE | `/todos/{id}/categories/{catId}` | Unlink a category |
|
||||
|
||||
#### Recurring Tasks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/recurring-tasks` | List recurring tasks (paginated, sortable, filterable) |
|
||||
| POST | `/recurring-tasks` | Create a recurring task |
|
||||
| GET | `/recurring-tasks/{id}` | Get a recurring task |
|
||||
| PUT | `/recurring-tasks/{id}` | Update a recurring task |
|
||||
| DELETE | `/recurring-tasks/{id}` | Delete a recurring task |
|
||||
| POST | `/recurring-tasks/{id}/categories` | Link a category |
|
||||
| DELETE | `/recurring-tasks/{id}/categories/{catId}` | Unlink a category |
|
||||
|
||||
#### Activity Logs
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/activity-logs` | List activity logs |
|
||||
| GET | `/activity-logs/{id}` | Get a single log entry |
|
||||
|
||||
#### User Themes
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/user/themes` | List installed themes |
|
||||
| POST | `/user/themes` | Install a theme |
|
||||
| PUT | `/user/themes/{id}` | Update theme settings |
|
||||
| DELETE | `/user/themes/{id}` | Uninstall a theme |
|
||||
|
||||
### Common Query Parameters
|
||||
|
||||
| Parameter | Type | Description | Example |
|
||||
|-----------|------|-------------|---------|
|
||||
| `page` | int | Page number (default: 1) | `?page=2` |
|
||||
| `per_page` | int | Items per page (default: 50, max: 200) | `?per_page=10` |
|
||||
| `sort` | string | Sort fields, `-` for descending, comma-separated | `?sort=-created_at,title` |
|
||||
| `status` | string | Filter by status (todos) | `?status=open` |
|
||||
| `favorite` | bool | Filter favorites (categories) | `?favorite=1` |
|
||||
| `limit` | int | Max items (activity logs, default: 50) | `?limit=100` |
|
||||
|
||||
### Sorting
|
||||
|
||||
Sortable fields vary per resource. Prefix a field with `-` for descending:
|
||||
|
||||
```
|
||||
GET /api/v1/todos?sort=-created_at,title
|
||||
GET /api/v1/categories?sort=name
|
||||
GET /api/v1/projects?sort=-created_at
|
||||
GET /api/v1/recurring-tasks?sort=title,-created_at
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
All responses follow a consistent envelope:
|
||||
|
||||
**Success:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Todos retrieved successfully",
|
||||
"data": [ ... ],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"total": 123,
|
||||
"last_page": 3,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Validation failed",
|
||||
"errors": {
|
||||
"title": "The todo title is required."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 400 | Bad request |
|
||||
| 401 | Unauthorized (missing/invalid API key) |
|
||||
| 403 | Forbidden (insufficient scope) |
|
||||
| 404 | Not found |
|
||||
| 409 | Conflict (duplicate) |
|
||||
| 422 | Validation failed |
|
||||
| 500 | Server error |
|
||||
|
||||
### Pagination
|
||||
|
||||
Paginated responses include a `pagination` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"total": 123,
|
||||
"last_page": 3,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Todo Status Values
|
||||
|
||||
- `open`
|
||||
- `in_progress`
|
||||
- `completed`
|
||||
- `archived`
|
||||
|
||||
### Recurring Task Schedule Values
|
||||
|
||||
- `daily`
|
||||
- `weekly`
|
||||
- `monthly`
|
||||
- `custom` (requires `custom_days` array, e.g. `["mon","wed","fri"]`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests via composer
|
||||
composer run test
|
||||
|
||||
# Or use phpunit directly
|
||||
./vendor/bin/phpunit
|
||||
|
||||
# Run only API tests
|
||||
./vendor/bin/phpunit tests/api
|
||||
|
||||
# With code coverage
|
||||
./vendor/bin/phpunit --coverage-text
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
|
||||
Integration tests use your configured MySQL database. Make sure migrations are applied first:
|
||||
|
||||
```bash
|
||||
php spark migrate
|
||||
```
|
||||
|
||||
For a dedicated test database, uncomment the test DB config in `phpunit.xml.dist`:
|
||||
|
||||
```xml
|
||||
<env name="database.tests.hostname" value="localhost"/>
|
||||
<env name="database.tests.database" value="todo_app_test"/>
|
||||
<env name="database.tests.username" value="root"/>
|
||||
<env name="database.tests.password" value=""/>
|
||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||
```
|
||||
|
||||
Then create it and migrate:
|
||||
|
||||
```bash
|
||||
mysql -e "CREATE DATABASE IF NOT EXISTS todo_app_test;"
|
||||
php spark migrate
|
||||
```
|
||||
|
||||
### Test Suite
|
||||
|
||||
| Directory | Description |
|
||||
|-----------|-------------|
|
||||
| `tests/api/ApiTest.php` | Full API integration tests (auth, CRUD, filtering, error handling) |
|
||||
| `tests/unit/` | Unit tests for individual components |
|
||||
| `tests/database/` | Database migration and seed tests |
|
||||
| `tests/session/` | Session-related tests |
|
||||
|
||||
The API test suite covers:
|
||||
|
||||
- Registration and login
|
||||
- Authentication errors (missing key, invalid credentials)
|
||||
- Full CRUD for all resources (categories, projects, todos, recurring tasks)
|
||||
- Category-todo / category-recurring-task linking
|
||||
- Status and sort filtering
|
||||
- Activity logging verification
|
||||
- Ownership isolation (cross-user access denied)
|
||||
- Validation error responses
|
||||
- Pagination structure
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── app/
|
||||
│ ├── Commands/ # Spark CLI commands
|
||||
│ │ ├── TestModels.php # Model testing command
|
||||
│ │ └── GenerateApiDocs.php # OpenAPI → HTML docs generator
|
||||
│ ├── Config/ # Application configuration
|
||||
│ ├── Controllers/
|
||||
│ │ ├── Api/
|
||||
│ │ │ ├── BaseController.php # Shared API helpers (pagination, JWT, responses)
|
||||
│ │ │ └── V1/
|
||||
│ │ │ ├── AuthController.php
|
||||
│ │ │ ├── CategoryController.php
|
||||
│ │ │ ├── ProjectController.php
|
||||
│ │ │ ├── TodoController.php
|
||||
│ │ │ ├── RecurringTaskController.php
|
||||
│ │ │ ├── UserController.php
|
||||
│ │ │ ├── ActivityLogController.php
|
||||
│ │ │ ├── MarketplaceController.php
|
||||
│ │ │ └── UserThemeController.php
|
||||
│ │ ├── BaseController.php
|
||||
│ │ ├── Home.php
|
||||
│ │ └── ThemeStore.php
|
||||
│ ├── Database/
|
||||
│ │ ├── Migrations/ # 16 migration files (users → api_auth_keys)
|
||||
│ │ └── Seeds/ # Sample data, themes, AI providers
|
||||
│ ├── Filters/
|
||||
│ │ └── ApiAuthFilter.php # API key authentication filter
|
||||
│ ├── Models/ # Database models (TodoModel, CategoryModel, etc.)
|
||||
│ └── Views/ # Error pages, welcome message, theme store
|
||||
├── openapi/
|
||||
│ └── openapi.yaml # Canonical OpenAPI 3.0 specification
|
||||
├── public/
|
||||
│ ├── api-docs.html # Generated API documentation (gitignored?)
|
||||
│ ├── index.php # Front controller
|
||||
│ └── themes/ # Uploaded theme CSS files
|
||||
├── tests/
|
||||
│ ├── api/ApiTest.php # Full API integration tests
|
||||
│ ├── unit/ # Unit tests
|
||||
│ ├── database/ # Database tests
|
||||
│ └── _support/ # Test helpers, models, seeds
|
||||
├── writable/ # Logs, cache, uploads
|
||||
├── composer.json
|
||||
├── env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Schema Overview
|
||||
|
||||
The database consists of 12 tables:
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `users` | User accounts (email, password hash, settings) |
|
||||
| `api_auth_keys` | API keys (hashed, scoped, expirable) |
|
||||
| `categories` | User-defined categories (with hex color) |
|
||||
| `projects` | User-defined projects |
|
||||
| `todos` | Tasks with status, due dates, project links |
|
||||
| `todo_categories` | Many-to-many: todos ↔ categories |
|
||||
| `recurring_tasks` | Recurring task templates (daily/weekly/etc.) |
|
||||
| `recurring_task_categories` | Many-to-many: recurring_tasks ↔ categories |
|
||||
| `activity_logs` | Audit trail (CRUD events, login, etc.) |
|
||||
| `marketplace_themes` | Published theme definitions |
|
||||
| `user_themes` | Per-user theme installations |
|
||||
| `ai_chats / ai_messages / ai_providers / user_ai_settings / user_api_keys` | AI assistant features |
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Run all pending migrations
|
||||
php spark migrate
|
||||
|
||||
# Roll back all migrations
|
||||
php spark migrate:rollback
|
||||
|
||||
# Seed sample data
|
||||
php spark db:seed SampleDataSeeder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Add the route in `app/Config/Routes.php`
|
||||
2. Create the controller method (extends `App\Controllers\Api\BaseController`)
|
||||
3. Create the model (extends `CodeIgniter\Model`)
|
||||
4. Write migration if needed
|
||||
5. Update `openapi/openapi.yaml` with the new endpoint
|
||||
6. Run `php spark generate:api-docs` to regenerate HTML docs
|
||||
7. Write tests in `tests/api/ApiTest.php`
|
||||
|
||||
### Updating Documentation
|
||||
|
||||
The **single source of truth** is `openapi/openapi.yaml`. After any API change:
|
||||
|
||||
1. Update the YAML spec
|
||||
2. Run `php spark generate:api-docs`
|
||||
3. Commit both files
|
||||
|
||||
### Available Spark Commands
|
||||
|
||||
```bash
|
||||
php spark list # List all available commands
|
||||
php spark generate:api-docs # Generate HTML docs from OpenAPI spec
|
||||
php spark generate:api-docs --watch # Validate spec only
|
||||
php spark migrate # Run database migrations
|
||||
php spark db:seed # Seed the database
|
||||
composer run test # Run all tests
|
||||
php spark test:models # Test models
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
**New todo with category:**
|
||||
```bash
|
||||
KEY="todo_your_key_here"
|
||||
|
||||
# Create category
|
||||
CAT=$(curl -s -X POST http://localhost:8080/api/v1/categories \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Work","color":"#3B82F6"}')
|
||||
CAT_ID=$(echo $CAT | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Create todo with that category
|
||||
curl -s -X POST http://localhost:8080/api/v1/todos \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"Finish report\",\"status\":\"open\",\"category_id\":\"$CAT_ID\"}"
|
||||
```
|
||||
|
||||
**Filter and sort todos:**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/todos?status=open&sort=-due_date,title&per_page=5" \
|
||||
-H "X-API-Key: $KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Keep the OpenAPI spec (`openapi/openapi.yaml`) in sync with code changes
|
||||
2. Run `php spark generate:api-docs --watch` to validate your YAML changes
|
||||
3. Write tests for new endpoints
|
||||
4. Run the full test suite before pushing
|
||||
5. Follow CodeIgniter 4 conventions
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
208
app/Commands/GenerateApiDocs.php
Normal file
208
app/Commands/GenerateApiDocs.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
/**
|
||||
* GenerateApiDocs
|
||||
*
|
||||
* Generates a standalone HTML documentation page from the OpenAPI spec.
|
||||
* Uses Redoc (CDN) for rendering.
|
||||
*
|
||||
* Usage: php spark generate:api-docs
|
||||
* php spark generate:api-docs --watch (validate only, no write)
|
||||
* php spark generate:api-docs --serve (print live server URL)
|
||||
*/
|
||||
class GenerateApiDocs extends BaseCommand
|
||||
{
|
||||
protected $group = 'Documentation';
|
||||
protected $name = 'generate:api-docs';
|
||||
protected $description = 'Generate API documentation HTML from openapi/openapi.yaml';
|
||||
protected $usage = 'generate:api-docs';
|
||||
protected $arguments = [];
|
||||
protected $options = [
|
||||
'--watch' => 'Validate YAML only, do not write HTML',
|
||||
'--serve' => 'Print the URL at which the docs are served',
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$projectRoot = ROOTPATH;
|
||||
$openapiFile = $projectRoot . 'openapi/openapi.yaml';
|
||||
$outputFile = $projectRoot . 'public/api-docs.html';
|
||||
|
||||
// ── Validate YAML exists ──────────────────────────────────────────
|
||||
if (!file_exists($openapiFile)) {
|
||||
CLI::error('[ERROR] openapi/openapi.yaml not found at: ' . $openapiFile);
|
||||
CLI::write('Create it first, then run this command again.', 'yellow');
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
$yamlContent = file_get_contents($openapiFile);
|
||||
if (empty($yamlContent)) {
|
||||
CLI::error('[ERROR] openapi/openapi.yaml is empty.');
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// Basic structural validation (line count, presence of openapi/info/paths)
|
||||
$lines = explode("\n", $yamlContent);
|
||||
$hasOpenapi = preg_match('/^openapi:/m', $yamlContent);
|
||||
$hasInfo = preg_match('/^info:/m', $yamlContent);
|
||||
$hasPaths = preg_match('/^paths:/m', $yamlContent);
|
||||
|
||||
CLI::write(sprintf(' Spec file: %s', $openapiFile), 'green');
|
||||
CLI::write(sprintf(' Size: %d bytes', strlen($yamlContent)), 'green');
|
||||
CLI::write(sprintf(' Lines: %d', count($lines)), 'green');
|
||||
|
||||
$errors = [];
|
||||
if (!$hasOpenapi) $errors[] = 'Missing "openapi:" version declaration';
|
||||
if (!$hasInfo) $errors[] = 'Missing "info:" section';
|
||||
if (!$hasPaths) $errors[] = 'Missing "paths:" section';
|
||||
|
||||
$totalPaths = 0;
|
||||
if (preg_match_all('/^\s{2}\/[a-z]/m', $yamlContent, $matches)) {
|
||||
$totalPaths = count($matches[0]);
|
||||
}
|
||||
|
||||
CLI::write(sprintf(' Endpoints: %d', $totalPaths), 'green');
|
||||
|
||||
if (!empty($errors)) {
|
||||
CLI::error('[VALIDATION] ' . count($errors) . ' issue(s) found:');
|
||||
foreach ($errors as $err) {
|
||||
CLI::write(' - ' . $err, 'red');
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
CLI::write('[VALIDATION] OpenAPI spec looks valid.', 'green');
|
||||
|
||||
// ── --watch mode: stop here ────────────────────────────────────────
|
||||
if (isset($params['watch']) || array_key_exists('watch', $params)) {
|
||||
CLI::write('Watch mode — no files written.', 'yellow');
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
// ── Generate HTML ────────────────────────────────────────────────
|
||||
$apiTitle = 'Todo App API Documentation';
|
||||
|
||||
// Escape YAML for embedding as a JS template literal.
|
||||
// Safe: escape backtick, backslash, and template substitution.
|
||||
$escapedYaml = str_replace(
|
||||
['\\', '`', '${'],
|
||||
['\\\\', '\\`', '\\${'],
|
||||
$yamlContent
|
||||
);
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{$apiTitle}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.css" rel="stylesheet" />
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: #f8f9fa; }
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
}
|
||||
.topbar h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.topbar .subtitle { font-size: 13px; color: #94a3b8; margin-top: 2px; }
|
||||
.topbar .badge {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
#redoc-container { min-height: calc(100vh - 64px); }
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>{$apiTitle}</h1>
|
||||
<div class="subtitle">Todo App Backend — OpenAPI 3.0</div>
|
||||
</div>
|
||||
<div class="badge">Generated: GENERATED_DATE</div>
|
||||
</div>
|
||||
<div class="loading" id="loading">Loading API documentation...</div>
|
||||
<div id="redoc-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
<script>
|
||||
var yamlText = `YAML_CONTENT`;
|
||||
var spec = jsyaml.load(yamlText);
|
||||
Redoc.init(
|
||||
spec,
|
||||
{
|
||||
scrollYOffset: 64,
|
||||
hideDownloadButton: false,
|
||||
expandResponses: "200,201",
|
||||
hideSingleRequestSampleTab: false,
|
||||
sortPropsAlphabetically: false,
|
||||
requiredPropsFirst: true,
|
||||
showObjectSchemaExamples: true,
|
||||
theme: {
|
||||
colors: { primary: { main: '#3b82f6' } },
|
||||
sidebar: { backgroundColor: '#ffffff', width: '280px' },
|
||||
rightPanel: { backgroundColor: '#1e293b' }
|
||||
},
|
||||
nativeScrollbars: true
|
||||
},
|
||||
document.getElementById('redoc-container')
|
||||
);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$html = str_replace(
|
||||
['YAML_CONTENT', 'GENERATED_DATE'],
|
||||
[$escapedYaml, date('Y-m-d H:i:s')],
|
||||
$html
|
||||
);
|
||||
|
||||
file_put_contents($outputFile, $html);
|
||||
|
||||
CLI::write(sprintf('[DONE] Docs generated: %s', $outputFile), 'green');
|
||||
CLI::write(sprintf(' Size: %d bytes', filesize($outputFile)), 'green');
|
||||
|
||||
if (isset($params['serve']) || array_key_exists('serve', $params)) {
|
||||
$baseUrl = CLI::getOption('base-url') ?? 'http://localhost:8080';
|
||||
CLI::write(sprintf(' Open in browser: %s/api-docs.html', $baseUrl), 'cyan');
|
||||
}
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class App extends BaseConfig
|
||||
*
|
||||
* E.g., http://example.com/
|
||||
*/
|
||||
public string $baseURL = 'http://localhost:8080/';
|
||||
public string $baseURL = 'http://localhost/Todo-App-Backend/public/';
|
||||
|
||||
/**
|
||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||
|
||||
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
|
||||
* - ['http://localhost:8080']
|
||||
* - ['https://www.example.com']
|
||||
*/
|
||||
'allowedOrigins' => [],
|
||||
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost'],
|
||||
|
||||
/**
|
||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
||||
*/
|
||||
'supportsCredentials' => false,
|
||||
'supportsCredentials' => true,
|
||||
|
||||
/**
|
||||
* Set headers to allow.
|
||||
@@ -68,7 +68,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
*/
|
||||
'allowedHeaders' => [],
|
||||
'allowedHeaders' => ['Content-Type', 'Authorization', 'X-API-Key', 'Accept', 'Fetch'],
|
||||
|
||||
/**
|
||||
* Set headers to expose.
|
||||
@@ -93,7 +93,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
*/
|
||||
'allowedMethods' => [],
|
||||
'allowedMethods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'FETCH'],
|
||||
|
||||
/**
|
||||
* Set how many seconds the results of a preflight request can be cached.
|
||||
|
||||
@@ -34,6 +34,7 @@ class Filters extends BaseFilters
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'apiauth' => \App\Filters\ApiAuthFilter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ class Filters extends BaseFilters
|
||||
*/
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'cors',
|
||||
// 'honeypot',
|
||||
// 'csrf',
|
||||
// 'invalidchars',
|
||||
|
||||
@@ -5,4 +5,113 @@ use CodeIgniter\Router\RouteCollection;
|
||||
/**
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$routes->get('/', static function () {
|
||||
return redirect()->to('/themes');
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
// ============================================================================
|
||||
// API Routes - Version 1.0
|
||||
// ============================================================================
|
||||
|
||||
// Catch-all CORS preflight handler for all API routes
|
||||
$routes->options('api/v1/(:any)', function () {
|
||||
$response = service('response');
|
||||
return $response->setStatusCode(200)
|
||||
->setHeader('Access-Control-Allow-Origin', '*')
|
||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||
});
|
||||
|
||||
// Public endpoints (no authentication required)
|
||||
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => 'cors'], function ($routes) {
|
||||
// Authentication
|
||||
$routes->options('auth/register', 'AuthController::options');
|
||||
$routes->post('auth/register', 'AuthController::register');
|
||||
$routes->options('auth/login', 'AuthController::options');
|
||||
$routes->post('auth/login', 'AuthController::login');
|
||||
$routes->options('auth/api-key', 'AuthController::options');
|
||||
$routes->post('auth/api-key', 'AuthController::createApiKey');
|
||||
|
||||
// Marketplace - Public access
|
||||
$routes->get('marketplace/themes', 'MarketplaceController::index');
|
||||
$routes->get('marketplace/themes/(:num)', 'MarketplaceController::show/$1');
|
||||
|
||||
// JWT Authentication
|
||||
$routes->post('auth/jwt/register', 'AuthController::jwtRegister');
|
||||
$routes->post('auth/jwt/login', 'AuthController::jwtLogin');
|
||||
$routes->post('auth/jwt/refresh', 'AuthController::jwtRefresh');
|
||||
});
|
||||
|
||||
// Protected endpoints (API key authentication required)
|
||||
$routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => ['cors', 'apiauth']], function ($routes) {
|
||||
// User endpoints
|
||||
$routes->get('user/profile', 'UserController::profile');
|
||||
$routes->put('user/profile', 'UserController::updateProfile');
|
||||
$routes->get('user/api-keys', 'UserController::listApiKeys');
|
||||
$routes->post('user/api-keys', 'UserController::createApiKey');
|
||||
$routes->delete('user/api-keys/(:segment)', 'UserController::revokeApiKey/$1');
|
||||
|
||||
// Categories
|
||||
$routes->get('categories', 'CategoryController::index');
|
||||
$routes->post('categories', 'CategoryController::create');
|
||||
$routes->get('categories/(:segment)', 'CategoryController::show/$1');
|
||||
$routes->put('categories/(:segment)', 'CategoryController::update/$1');
|
||||
$routes->delete('categories/(:segment)', 'CategoryController::delete/$1');
|
||||
|
||||
// Projects
|
||||
$routes->get('projects', 'ProjectController::index');
|
||||
$routes->post('projects', 'ProjectController::create');
|
||||
$routes->get('projects/(:segment)', 'ProjectController::show/$1');
|
||||
$routes->put('projects/(:segment)', 'ProjectController::update/$1');
|
||||
$routes->delete('projects/(:segment)', 'ProjectController::delete/$1');
|
||||
|
||||
// Todos
|
||||
$routes->get('todos', 'TodoController::index');
|
||||
$routes->post('todos', 'TodoController::create');
|
||||
$routes->get('todos/(:segment)', 'TodoController::show/$1');
|
||||
$routes->put('todos/(:segment)', 'TodoController::update/$1');
|
||||
$routes->delete('todos/(:segment)', 'TodoController::delete/$1');
|
||||
$routes->post('todos/(:segment)/categories', 'TodoController::addCategory/$1');
|
||||
$routes->delete('todos/(:segment)/categories/(:segment)', 'TodoController::removeCategory/$1/$2');
|
||||
|
||||
// Recurring Tasks
|
||||
$routes->get('recurring-tasks', 'RecurringTaskController::index');
|
||||
$routes->post('recurring-tasks', 'RecurringTaskController::create');
|
||||
$routes->get('recurring-tasks/(:segment)', 'RecurringTaskController::show/$1');
|
||||
$routes->put('recurring-tasks/(:segment)', 'RecurringTaskController::update/$1');
|
||||
$routes->delete('recurring-tasks/(:segment)', 'RecurringTaskController::delete/$1');
|
||||
$routes->post('recurring-tasks/(:segment)/categories', 'RecurringTaskController::addCategory/$1');
|
||||
$routes->delete('recurring-tasks/(:segment)/categories/(:segment)', 'RecurringTaskController::removeCategory/$1/$2');
|
||||
|
||||
// Activity Logs
|
||||
$routes->get('activity-logs', 'ActivityLogController::index');
|
||||
$routes->get('activity-logs/(:segment)', 'ActivityLogController::show/$1');
|
||||
|
||||
// User Themes
|
||||
$routes->get('user/themes', 'UserThemeController::index');
|
||||
$routes->post('user/themes', 'UserThemeController::create');
|
||||
$routes->put('user/themes/(:segment)', 'UserThemeController::update/$1');
|
||||
$routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1');
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->options('/themes', static function () {
|
||||
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||
header('Vary: Origin');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->options('/themes/upload', static function () {
|
||||
$origin = service('request')->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept');
|
||||
header('Vary: Origin');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
359
app/Controllers/Api/BaseController.php
Normal file
359
app/Controllers/Api/BaseController.php
Normal file
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class BaseController extends ResourceController
|
||||
{
|
||||
/**
|
||||
* Get the authenticated user from the request
|
||||
*/
|
||||
protected function getUser(): ?array
|
||||
{
|
||||
return $this->request->user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user ID
|
||||
*/
|
||||
protected function getUserId(): ?string
|
||||
{
|
||||
$user = $this->getUser();
|
||||
return $user['id'] ?? null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Pagination & Sorting
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Extract pagination params from the query string.
|
||||
*
|
||||
* Returns [page, perPage].
|
||||
* Default: page=1, perPage=50. Max perPage = 200.
|
||||
*/
|
||||
protected function getPaginationParams(): array
|
||||
{
|
||||
$page = max(1, (int) $this->request->getGet('page'));
|
||||
$perPage = min(200, max(1, (int) ($this->request->getGet('per_page') ?? 50)));
|
||||
return [$page, $perPage];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract allowed sort params from the query string.
|
||||
*
|
||||
* ?sort=title,-created_at → ASC on title, DESC on created_at
|
||||
* Only fields listed in $allowed will be accepted.
|
||||
*/
|
||||
protected function getSortParams(array $allowed = []): array
|
||||
{
|
||||
$raw = $this->request->getGet('sort');
|
||||
if (empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = explode(',', $raw);
|
||||
$sorts = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
if (empty($part)) continue;
|
||||
|
||||
$dir = 'ASC';
|
||||
if ($part[0] === '-') {
|
||||
$dir = 'DESC';
|
||||
$part = substr($part, 1);
|
||||
}
|
||||
|
||||
if (in_array($part, $allowed, true)) {
|
||||
$sorts[$part] = $dir;
|
||||
}
|
||||
}
|
||||
|
||||
return $sorts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract allowed filter params from the query string.
|
||||
*
|
||||
* ?status=open&favorite=1
|
||||
* Only fields listed in $allowed will be accepted.
|
||||
*/
|
||||
protected function getFilterParams(array $allowed = []): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
foreach ($allowed as $field) {
|
||||
$value = $this->request->getGet($field);
|
||||
if ($value !== null && $value !== '') {
|
||||
$filters[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sorting to a model query builder.
|
||||
*/
|
||||
protected function applySort($query, array $sorts): void
|
||||
{
|
||||
foreach ($sorts as $field => $dir) {
|
||||
$query->orderBy($field, $dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to a model query builder (simple WHERE).
|
||||
*/
|
||||
protected function applyFilters($query, array $filters): void
|
||||
{
|
||||
foreach ($filters as $field => $value) {
|
||||
$query->where($field, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a paginated response with meta information.
|
||||
*/
|
||||
protected function paginatedResponse($query, string $message = 'Success', int $statusCode = 200)
|
||||
{
|
||||
[$page, $perPage] = $this->getPaginationParams();
|
||||
|
||||
$total = $query->countAllResults(false);
|
||||
$data = $query->get($perPage, ($page - 1) * $perPage)->getResultArray();
|
||||
$lastPage = (int) ceil($total / max($perPage, 1));
|
||||
|
||||
return $this->successResponse($data, $message, $statusCode, [
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'last_page' => $lastPage,
|
||||
'has_more' => $page < $lastPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Success / Error Responses
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Success response
|
||||
*/
|
||||
protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200, array $extraMeta = [])
|
||||
{
|
||||
$body = [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
if (!empty($extraMeta)) {
|
||||
foreach ($extraMeta as $key => $value) {
|
||||
$body[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response
|
||||
->setStatusCode($statusCode)
|
||||
->setHeader('Access-Control-Allow-Origin', '*')
|
||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||
->setJSON($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response with structured error info
|
||||
*/
|
||||
protected function errorResponse(string $message, int $statusCode = 400, $errors = null)
|
||||
{
|
||||
$body = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$body['errors'] = $errors;
|
||||
}
|
||||
|
||||
return $this->response
|
||||
->setStatusCode($statusCode)
|
||||
->setHeader('Access-Control-Allow-Origin', '*')
|
||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||
->setJSON($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error shorthand (422)
|
||||
*/
|
||||
protected function validationErrorResponse($errors): void
|
||||
{
|
||||
$this->errorResponse('Validation failed', 422, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found shorthand (404)
|
||||
*/
|
||||
protected function notFoundResponse(string $resource = 'Resource'): void
|
||||
{
|
||||
$this->errorResponse("{$resource} not found", 404);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Validation
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Validate request data using the rules defined in a model.
|
||||
*
|
||||
* Returns true on success, sends a 422 JSON response and returns false on failure.
|
||||
*/
|
||||
protected function validateWithModel(\CodeIgniter\Model $model): bool
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
$rules = $model->getValidationRules();
|
||||
$errors = $model->getValidationMessages();
|
||||
|
||||
if (empty($rules)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$validation->setRules($rules, $errors);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy simple validation for controllers that define rules inline.
|
||||
*/
|
||||
protected function validateRequest(array $rules): bool
|
||||
{
|
||||
$validation = \Config\Services::validation();
|
||||
|
||||
foreach ($rules as $field => $rule) {
|
||||
if (is_array($rule) && isset($rule['rules'])) {
|
||||
$validation->setRules([$field => $rule['rules']], $rule['errors'] ?? []);
|
||||
} else {
|
||||
$validation->setRule($field, $field, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
$this->errorResponse('Validation failed', 422, $validation->getErrors());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Activity Logging
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Log an activity to the activity_logs table.
|
||||
* Safe — catches and logs errors silently so the main request is never broken.
|
||||
*/
|
||||
protected function logActivity(string $action, string $entityType, ?string $entityId, ?array $details = null): void
|
||||
{
|
||||
try {
|
||||
$userId = $this->getUserId();
|
||||
$logModel = new \App\Models\ActivityLogModel();
|
||||
|
||||
$logModel->logActivity([
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'details' => $details ? json_encode($details) : null,
|
||||
'ip_address' => $this->request->getIPAddress(),
|
||||
'user_agent' => $this->request->getUserAgent()->getAgentString(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'Failed to log activity: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// JWT helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* JWT secret key — should be read from env in production.
|
||||
*/
|
||||
protected function getJwtSecret(): string
|
||||
{
|
||||
return $_ENV['JWT_SECRET'] ?? 'todo-app-jwt-secret-change-in-production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT from the Authorization header.
|
||||
* Returns the payload on success, null on failure.
|
||||
*/
|
||||
protected function decodeJwtFromRequest(): ?array
|
||||
{
|
||||
$header = $this->request->getHeaderLine('Authorization');
|
||||
if (empty($header)) return null;
|
||||
|
||||
if (strpos($header, 'Bearer ') !== 0) return null;
|
||||
|
||||
$token = substr($header, 7);
|
||||
return $this->decodeJwt($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and verify a JWT token.
|
||||
*/
|
||||
protected function decodeJwt(string $token): ?array
|
||||
{
|
||||
try {
|
||||
$key = $this->getJwtSecret();
|
||||
$jwt = \Firebase\JWT\JWT::decode($token, new \Firebase\JWT\Key($key, 'HS256'));
|
||||
return (array) $jwt;
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'JWT decode failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a payload into a JWT token.
|
||||
*/
|
||||
protected function encodeJwt(array $payload): string
|
||||
{
|
||||
$key = $this->getJwtSecret();
|
||||
$issuedAt = time();
|
||||
$payload['iat'] = $issuedAt;
|
||||
$payload['exp'] = $issuedAt + 3600; // 1 hour default
|
||||
|
||||
return \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Generate UUID v4
|
||||
*/
|
||||
protected function generateUuid(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
45
app/Controllers/Api/V1/ActivityLogController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\ActivityLogModel;
|
||||
|
||||
class ActivityLogController extends BaseController
|
||||
{
|
||||
protected $activityLogModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->activityLogModel = new ActivityLogModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity logs for the authenticated user
|
||||
* GET /api/v1/activity-logs
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$limit = (int)($this->request->getVar('limit') ?? 50);
|
||||
$logs = $this->activityLogModel->getByUser($userId, $limit);
|
||||
|
||||
return $this->successResponse($logs, 'Activity logs retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific activity log
|
||||
* GET /api/v1/activity-logs/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$log = $this->activityLogModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$log) {
|
||||
return $this->errorResponse('Activity log not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($log, 'Activity log retrieved successfully');
|
||||
}
|
||||
}
|
||||
383
app/Controllers/Api/V1/AuthController.php
Normal file
383
app/Controllers/Api/V1/AuthController.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ApiAuthKeyModel;
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $apiAuthKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight requests
|
||||
* OPTIONS /api/v1/auth/*
|
||||
*/
|
||||
public function options()
|
||||
{
|
||||
return $this->response
|
||||
->setStatusCode(200)
|
||||
->setHeader('Access-Control-Allow-Origin', '*')
|
||||
->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* POST /api/v1/auth/register
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => [
|
||||
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||
'errors' => [
|
||||
'required' => 'Email is required',
|
||||
'valid_email' => 'Please provide a valid email address',
|
||||
'is_unique' => 'This email is already registered'
|
||||
]
|
||||
],
|
||||
'password' => [
|
||||
'rules' => 'required|min_length[8]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required',
|
||||
'min_length' => 'Password must be at least 8 characters long'
|
||||
]
|
||||
],
|
||||
'name' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'Name is required',
|
||||
'max_length' => 'Name must not exceed 255 characters'
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate UUID for user
|
||||
$userId = $this->generateUuid();
|
||||
|
||||
// Create user
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $json['email'],
|
||||
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
|
||||
'name' => $json['name'],
|
||||
'avatar_url' => $json['avatar_url'] ?? null,
|
||||
'settings' => isset($json['settings']) && $json['settings'] ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$this->userModel->insert($userData);
|
||||
|
||||
// Create API key for the new user
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$userId,
|
||||
'Default API Key',
|
||||
['read', 'write'],
|
||||
null
|
||||
);
|
||||
|
||||
// Remove sensitive data from response
|
||||
unset($userData['password_hash']);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => $userData,
|
||||
'api_key' => $apiKey['key'],
|
||||
'key_prefix' => $apiKey['prefix'],
|
||||
], 'User registered successfully', 201);
|
||||
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user and return API key
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
'password' => 'required',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Authenticate user
|
||||
$user = $this->userModel->where('email', $json['email'])->first();
|
||||
|
||||
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||
return $this->errorResponse('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
// Check if user has an existing active API key
|
||||
$existingKey = $this->apiAuthKeyModel
|
||||
->where('user_id', $user['id'])
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if ($existingKey) {
|
||||
// Return existing key
|
||||
return $this->successResponse([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
'api_key_prefix' => $existingKey['key_prefix'],
|
||||
'message' => 'Using existing API key',
|
||||
], 'Login successful');
|
||||
}
|
||||
|
||||
// Create new API key
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$user['id'],
|
||||
'Login API Key',
|
||||
['read', 'write'],
|
||||
null
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
'api_key' => $apiKey['key'],
|
||||
'key_prefix' => $apiKey['prefix'],
|
||||
], 'Login successful');
|
||||
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
|
||||
return $this->errorResponse('Database error: ' . $e->getMessage(), 500);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('An error occurred: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API key using email and password (legacy endpoint)
|
||||
* POST /api/v1/auth/api-key
|
||||
*/
|
||||
public function createApiKey()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
'password' => 'required|min_length[6]',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
$user = $this->userModel->where('email', $json['email'])->first();
|
||||
|
||||
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||
return $this->errorResponse('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
// Create API key
|
||||
$name = $json['name'] ?? 'API Key';
|
||||
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||
$expiresAt = $json['expires_at'] ?? null;
|
||||
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$user['id'],
|
||||
$name,
|
||||
$scopes,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'key' => $apiKey['key'],
|
||||
'prefix' => $apiKey['prefix'],
|
||||
'name' => $apiKey['name'],
|
||||
'scopes' => $apiKey['scopes'],
|
||||
'expires_at' => $apiKey['expires_at'],
|
||||
], 'API key created successfully');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// JWT Authentication
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Register a new user and return JWT + API key
|
||||
* POST /api/v1/auth/jwt/register
|
||||
*/
|
||||
public function jwtRegister()
|
||||
{
|
||||
// Reuse the existing register validation logic
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => [
|
||||
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||
'errors' => [
|
||||
'required' => 'Email is required',
|
||||
'valid_email' => 'Please provide a valid email address',
|
||||
'is_unique' => 'This email is already registered',
|
||||
],
|
||||
],
|
||||
'password' => [
|
||||
'rules' => 'required|min_length[8]',
|
||||
'errors' => [
|
||||
'required' => 'Password is required',
|
||||
'min_length' => 'Password must be at least 8 characters long',
|
||||
],
|
||||
],
|
||||
'name' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'Name is required',
|
||||
'max_length' => 'Name must not exceed 255 characters',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$userId = $this->generateUuid();
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $json['email'],
|
||||
'password_hash' => password_hash($json['password'], PASSWORD_BCRYPT),
|
||||
'name' => $json['name'],
|
||||
'avatar_url' => $json['avatar_url'] ?? null,
|
||||
'settings' => isset($json['settings']) ? json_encode($json['settings']) : json_encode(['theme' => 'light']),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$this->userModel->insert($userData);
|
||||
|
||||
// Create API key for the new user
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$userId,
|
||||
'Default API Key',
|
||||
['read', 'write'],
|
||||
null
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
$jwt = $this->encodeJwt([
|
||||
'sub' => $userId,
|
||||
'email' => $json['email'],
|
||||
'name' => $json['name'],
|
||||
]);
|
||||
|
||||
unset($userData['password_hash']);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => $userData,
|
||||
'token' => $jwt,
|
||||
'api_key' => $apiKey['key'],
|
||||
], 'User registered successfully', 201);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('Registration failed: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email/password and return JWT
|
||||
* POST /api/v1/auth/jwt/login
|
||||
*/
|
||||
public function jwtLogin()
|
||||
{
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'email' => 'required|valid_email',
|
||||
'password' => 'required',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->userModel->where('email', $json['email'])->first();
|
||||
|
||||
if (!$user || !password_verify($json['password'], $user['password_hash'])) {
|
||||
return $this->errorResponse('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
$jwt = $this->encodeJwt([
|
||||
'sub' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
]);
|
||||
|
||||
return $this->successResponse([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
'token' => $jwt,
|
||||
], 'Login successful');
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse('Login failed: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh JWT token
|
||||
* POST /api/v1/auth/jwt/refresh
|
||||
*/
|
||||
public function jwtRefresh()
|
||||
{
|
||||
$payload = $this->decodeJwtFromRequest();
|
||||
|
||||
if (!$payload || empty($payload['sub'])) {
|
||||
return $this->errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
$user = $this->userModel->find($payload['sub']);
|
||||
if (!$user) {
|
||||
return $this->errorResponse('User not found', 401);
|
||||
}
|
||||
|
||||
$jwt = $this->encodeJwt([
|
||||
'sub' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
]);
|
||||
|
||||
return $this->successResponse(['token' => $jwt], 'Token refreshed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID
|
||||
*/
|
||||
|
||||
}
|
||||
166
app/Controllers/Api/V1/CategoryController.php
Normal file
166
app/Controllers/Api/V1/CategoryController.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\CategoryModel;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
protected $categoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categoryModel = new CategoryModel();
|
||||
}
|
||||
|
||||
const SORTABLE = ['name', 'created_at'];
|
||||
const FILTERABLE = ['favorite'];
|
||||
|
||||
/**
|
||||
* GET /api/v1/categories
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||
$sorts = $this->getSortParams(self::SORTABLE);
|
||||
|
||||
$builder = $this->categoryModel->where('user_id', $userId);
|
||||
$this->applyFilters($builder, $filters);
|
||||
|
||||
if (empty($sorts)) {
|
||||
$builder->orderBy('name', 'ASC');
|
||||
} else {
|
||||
$this->applySort($builder, $sorts);
|
||||
}
|
||||
|
||||
return $this->paginatedResponse($builder, 'Categories retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/categories
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if (!$this->validateWithModel($this->categoryModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
// Custom duplicate check (per user)
|
||||
$existing = $this->categoryModel
|
||||
->where('user_id', $userId)
|
||||
->where('name', $json['name'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('A category with this name already exists.', 409);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'name' => $json['name'],
|
||||
'color' => $json['color'],
|
||||
'favorite' => !empty($json['favorite']),
|
||||
];
|
||||
|
||||
$this->categoryModel->insert($data);
|
||||
$category = $this->categoryModel->find($data['id']);
|
||||
|
||||
$this->logActivity('category_created', 'category', $data['id'], [
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
|
||||
return $this->successResponse($category, 'Category created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/categories/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($category, 'Category retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/categories/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
// Duplicate check on rename
|
||||
if (!empty($json['name']) && strtolower($json['name']) !== strtolower($category['name'])) {
|
||||
$existing = $this->categoryModel
|
||||
->where('user_id', $userId)
|
||||
->where('name', $json['name'])
|
||||
->where('id !=', $id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('A category with this name already exists.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'color', 'favorite'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
// Convert boolean
|
||||
if (array_key_exists('favorite', $updateData)) {
|
||||
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||
}
|
||||
|
||||
$this->categoryModel->update($id, $updateData);
|
||||
$category = $this->categoryModel->find($id);
|
||||
|
||||
$this->logActivity('category_updated', 'category', $id, [
|
||||
'name' => $category['name'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse($category, 'Category updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/categories/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$category = $this->categoryModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return $this->errorResponse('Category not found', 404);
|
||||
}
|
||||
|
||||
$this->categoryModel->delete($id);
|
||||
|
||||
$this->logActivity('category_deleted', 'category', $id, [
|
||||
'name' => $category['name'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Category deleted successfully');
|
||||
}
|
||||
}
|
||||
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
42
app/Controllers/Api/V1/MarketplaceController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\MarketplaceThemeModel;
|
||||
|
||||
class MarketplaceController extends BaseController
|
||||
{
|
||||
protected $marketplaceThemeModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->marketplaceThemeModel = new MarketplaceThemeModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all marketplace themes
|
||||
* GET /api/v1/marketplace/themes
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$themes = $this->marketplaceThemeModel->getPublished();
|
||||
|
||||
return $this->successResponse($themes, 'Marketplace themes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific marketplace theme
|
||||
* GET /api/v1/marketplace/themes/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$theme = $this->marketplaceThemeModel->find($id);
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('Theme not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($theme, 'Theme retrieved successfully');
|
||||
}
|
||||
}
|
||||
138
app/Controllers/Api/V1/ProjectController.php
Normal file
138
app/Controllers/Api/V1/ProjectController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\ProjectModel;
|
||||
|
||||
class ProjectController extends BaseController
|
||||
{
|
||||
protected $projectModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->projectModel = new ProjectModel();
|
||||
}
|
||||
|
||||
const SORTABLE = ['name', 'created_at'];
|
||||
const FILTERABLE = [];
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||
$sorts = $this->getSortParams(self::SORTABLE);
|
||||
|
||||
$builder = $this->projectModel->where('user_id', $userId);
|
||||
$this->applyFilters($builder, $filters);
|
||||
|
||||
if (empty($sorts)) {
|
||||
$builder->orderBy('created_at', 'DESC');
|
||||
} else {
|
||||
$this->applySort($builder, $sorts);
|
||||
}
|
||||
|
||||
return $this->paginatedResponse($builder, 'Projects retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/projects
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if (!$this->validateWithModel($this->projectModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'name' => $json['name'],
|
||||
'description' => $json['description'] ?? null,
|
||||
'color' => $json['color'] ?? '#8B5CF6',
|
||||
];
|
||||
|
||||
$this->projectModel->insert($data);
|
||||
$project = $this->projectModel->find($data['id']);
|
||||
|
||||
$this->logActivity('project_created', 'project', $data['id'], [
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
|
||||
return $this->successResponse($project, 'Project created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/projects/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($project, 'Project retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/projects/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$allowedFields = ['name', 'description', 'color'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->projectModel->update($id, $updateData);
|
||||
$project = $this->projectModel->find($id);
|
||||
|
||||
$this->logActivity('project_updated', 'project', $id, [
|
||||
'name' => $project['name'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse($project, 'Project updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/projects/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$project = $this->projectModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$project) {
|
||||
return $this->errorResponse('Project not found', 404);
|
||||
}
|
||||
|
||||
$this->projectModel->delete($id);
|
||||
|
||||
$this->logActivity('project_deleted', 'project', $id, [
|
||||
'name' => $project['name'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Project deleted successfully');
|
||||
}
|
||||
}
|
||||
250
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
250
app/Controllers/Api/V1/RecurringTaskController.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\RecurringTaskModel;
|
||||
use App\Models\RecurringTaskCategoryModel;
|
||||
use App\Models\CategoryModel;
|
||||
|
||||
class RecurringTaskController extends BaseController
|
||||
{
|
||||
protected $recurringTaskModel;
|
||||
protected $recurringTaskCategoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->recurringTaskModel = new RecurringTaskModel();
|
||||
$this->recurringTaskCategoryModel = new RecurringTaskCategoryModel();
|
||||
}
|
||||
|
||||
const SORTABLE = ['title', 'schedule', 'created_at'];
|
||||
const FILTERABLE = ['schedule', 'favorite'];
|
||||
|
||||
/**
|
||||
* GET /api/v1/recurring-tasks
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||
$sorts = $this->getSortParams(self::SORTABLE);
|
||||
|
||||
$builder = $this->recurringTaskModel
|
||||
->select('recurring_tasks.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
|
||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||
->where('recurring_tasks.user_id', $userId)
|
||||
->groupBy('recurring_tasks.id');
|
||||
|
||||
$this->applyFilters($builder, $filters);
|
||||
|
||||
if (empty($sorts)) {
|
||||
$builder->orderBy('recurring_tasks.created_at', 'DESC');
|
||||
} else {
|
||||
$this->applySort($builder, $sorts);
|
||||
}
|
||||
|
||||
return $this->paginatedResponse($builder, 'Recurring tasks retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/recurring-tasks
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if (!$this->validateWithModel($this->recurringTaskModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'title' => $json['title'],
|
||||
'description' => $json['description'] ?? null,
|
||||
'schedule' => $json['schedule'] ?? 'weekly',
|
||||
'custom_days' => isset($json['custom_days']) ? json_encode($json['custom_days']) : '[]',
|
||||
'favorite' => !empty($json['favorite']),
|
||||
];
|
||||
|
||||
$this->recurringTaskModel->insert($data);
|
||||
|
||||
// Link category if provided
|
||||
if (!empty($json['category_id'])) {
|
||||
$this->linkCategory($data['id'], $json['category_id']);
|
||||
}
|
||||
|
||||
$this->logActivity('recurring_task_created', 'recurring_task', $data['id'], [
|
||||
'title' => $data['title'],
|
||||
]);
|
||||
|
||||
$task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']);
|
||||
|
||||
return $this->successResponse($task[0] ?? null, 'Recurring task created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/recurring-tasks/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$tasks = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||
|
||||
if (empty($tasks)) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($tasks[0], 'Recurring task retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/recurring-tasks/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
// Handle category update
|
||||
if (array_key_exists('category_id', $json)) {
|
||||
$this->recurringTaskCategoryModel->where('recurring_task_id', $id)->delete();
|
||||
if (!empty($json['category_id'])) {
|
||||
$this->linkCategory($id, $json['category_id']);
|
||||
}
|
||||
}
|
||||
|
||||
$allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
// Convert custom_days array to JSON string
|
||||
if (isset($updateData['custom_days']) && is_array($updateData['custom_days'])) {
|
||||
$updateData['custom_days'] = json_encode($updateData['custom_days']);
|
||||
}
|
||||
if (array_key_exists('favorite', $updateData)) {
|
||||
$updateData['favorite'] = !empty($updateData['favorite']);
|
||||
}
|
||||
|
||||
if (!empty($updateData)) {
|
||||
$this->recurringTaskModel->update($id, $updateData);
|
||||
}
|
||||
|
||||
$this->logActivity('recurring_task_updated', 'recurring_task', $id, [
|
||||
'title' => $task['title'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
$updated = $this->recurringTaskModel->getByUserWithCategories($userId, $id);
|
||||
|
||||
return $this->successResponse($updated[0] ?? null, 'Recurring task updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/recurring-tasks/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$task = $this->recurringTaskModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
$this->recurringTaskModel->delete($id);
|
||||
|
||||
$this->logActivity('recurring_task_deleted', 'recurring_task', $id, [
|
||||
'title' => $task['title'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Recurring task deleted successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/recurring-tasks/{id}/categories
|
||||
*/
|
||||
public function addCategory($taskId = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = ['category_id' => 'required'];
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
$existing = $this->recurringTaskCategoryModel
|
||||
->where('recurring_task_id', $taskId)
|
||||
->where('category_id', $json['category_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('Category already linked to this task', 409);
|
||||
}
|
||||
|
||||
$this->recurringTaskCategoryModel->insert([
|
||||
'recurring_task_id' => $taskId,
|
||||
'category_id' => $json['category_id'],
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Category added to recurring task successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}
|
||||
*/
|
||||
public function removeCategory($taskId = null, $categoryId = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
$task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first();
|
||||
if (!$task) {
|
||||
return $this->errorResponse('Recurring task not found', 404);
|
||||
}
|
||||
|
||||
$this->recurringTaskCategoryModel
|
||||
->where('recurring_task_id', $taskId)
|
||||
->where('category_id', $categoryId)
|
||||
->delete();
|
||||
|
||||
return $this->successResponse(null, 'Category removed from recurring task successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a category (internal helper)
|
||||
*/
|
||||
private function linkCategory(string $taskId, string $categoryId): void
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$categoryModel = new CategoryModel();
|
||||
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) return;
|
||||
|
||||
$existing = $this->recurringTaskCategoryModel
|
||||
->where('recurring_task_id', $taskId)
|
||||
->where('category_id', $categoryId)
|
||||
->first();
|
||||
|
||||
if (!$existing) {
|
||||
$this->recurringTaskCategoryModel->insert([
|
||||
'recurring_task_id' => $taskId,
|
||||
'category_id' => $categoryId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
276
app/Controllers/Api/V1/TodoController.php
Normal file
276
app/Controllers/Api/V1/TodoController.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\TodoModel;
|
||||
use App\Models\TodoCategoryModel;
|
||||
use App\Models\CategoryModel;
|
||||
|
||||
class TodoController extends BaseController
|
||||
{
|
||||
protected $todoModel;
|
||||
protected $todoCategoryModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->todoModel = new TodoModel();
|
||||
$this->todoCategoryModel = new TodoCategoryModel();
|
||||
}
|
||||
|
||||
// ── Allowed sort & filter fields ───────────────────────────────────────
|
||||
const SORTABLE = ['title', 'status', 'due_date', 'due_time', 'created_at', 'updated_at'];
|
||||
const FILTERABLE = ['status', 'project_id', 'sync_enabled', 'reminder_enabled', 'recurring_enabled'];
|
||||
|
||||
/**
|
||||
* GET /api/v1/todos
|
||||
* Paginated, sortable, filterable list of todos for the authenticated user.
|
||||
*
|
||||
* Query params:
|
||||
* page (int) – page number, default 1
|
||||
* per_page (int) – items per page, default 50, max 200
|
||||
* sort (string) – e.g. "title" or "-created_at,title"
|
||||
* status (string) – filter by status
|
||||
* project_id (string) – filter by project
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
$filters = $this->getFilterParams(self::FILTERABLE);
|
||||
$sorts = $this->getSortParams(self::SORTABLE);
|
||||
|
||||
$builder = $this->todoModel
|
||||
->select('todos.*, GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids, GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names')
|
||||
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||
->where('todos.user_id', $userId)
|
||||
->groupBy('todos.id');
|
||||
|
||||
// Apply filters
|
||||
$this->applyFilters($builder, $filters);
|
||||
|
||||
// Apply sorting (default: newest first)
|
||||
if (empty($sorts)) {
|
||||
$builder->orderBy('todos.created_at', 'DESC');
|
||||
} else {
|
||||
$this->applySort($builder, $sorts);
|
||||
}
|
||||
|
||||
return $this->paginatedResponse($builder, 'Todos retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/todos
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
if (!$this->validateWithModel($this->todoModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'title' => $json['title'],
|
||||
'description' => $json['description'] ?? null,
|
||||
'status' => $json['status'] ?? 'open',
|
||||
'due_date' => $json['due_date'] ?? null,
|
||||
'due_time' => $json['due_time'] ?? null,
|
||||
'sync_enabled' => !empty($json['sync_enabled']),
|
||||
'reminder_enabled' => !empty($json['reminder_enabled']),
|
||||
'recurring_enabled' => !empty($json['recurring_enabled']),
|
||||
'project_id' => $json['project_id'] ?? null,
|
||||
];
|
||||
|
||||
$this->todoModel->insert($data);
|
||||
|
||||
// Link category if provided
|
||||
if (!empty($json['category_id'])) {
|
||||
$this->linkCategory($data['id'], $json['category_id']);
|
||||
}
|
||||
|
||||
$this->logActivity('todo_created', 'todo', $data['id'], [
|
||||
'title' => $data['title'],
|
||||
]);
|
||||
|
||||
$todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
|
||||
|
||||
return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/todos/{id}
|
||||
*/
|
||||
public function show($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$todos = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||
|
||||
if (empty($todos)) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
return $this->successResponse($todos[0], 'Todo retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/todos/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$todo) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
// Handle category update separately (not a column in todos table)
|
||||
$hasCategoryUpdate = array_key_exists('category_id', $json);
|
||||
if ($hasCategoryUpdate) {
|
||||
$categoryId = $json['category_id'];
|
||||
$this->todoCategoryModel->where('todo_id', $id)->delete();
|
||||
if (!empty($categoryId)) {
|
||||
$this->linkCategory($id, $categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update todo fields
|
||||
$allowedFields = [
|
||||
'title', 'description', 'status', 'due_date', 'due_time',
|
||||
'sync_enabled', 'reminder_enabled', 'recurring_enabled', 'project_id',
|
||||
];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (!empty($updateData)) {
|
||||
// Convert boolean-ish values
|
||||
foreach (['sync_enabled', 'reminder_enabled', 'recurring_enabled'] as $boolField) {
|
||||
if (array_key_exists($boolField, $updateData)) {
|
||||
$updateData[$boolField] = !empty($updateData[$boolField]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->todoModel->update($id, $updateData);
|
||||
}
|
||||
|
||||
$this->logActivity('todo_updated', 'todo', $id, [
|
||||
'title' => $todo['title'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
$updated = $this->todoModel->getByUserWithCategories($userId, $id);
|
||||
|
||||
return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/todos/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$todo = $this->todoModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$todo) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
$this->todoModel->delete($id);
|
||||
|
||||
$this->logActivity('todo_deleted', 'todo', $id, [
|
||||
'title' => $todo['title'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Todo deleted successfully');
|
||||
}
|
||||
|
||||
// ── Category linking ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/v1/todos/{id}/categories
|
||||
*/
|
||||
public function addCategory($todoId = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = ['category_id' => 'required'];
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||
if (!$todo) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
$existing = $this->todoCategoryModel
|
||||
->where('todo_id', $todoId)
|
||||
->where('category_id', $json['category_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $this->errorResponse('Category already linked to this todo', 409);
|
||||
}
|
||||
|
||||
$this->todoCategoryModel->insert([
|
||||
'todo_id' => $todoId,
|
||||
'category_id' => $json['category_id'],
|
||||
]);
|
||||
|
||||
return $this->successResponse(null, 'Category added to todo successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/todos/{id}/categories/{categoryId}
|
||||
*/
|
||||
public function removeCategory($todoId = null, $categoryId = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
$todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first();
|
||||
if (!$todo) {
|
||||
return $this->errorResponse('Todo not found', 404);
|
||||
}
|
||||
|
||||
$this->todoCategoryModel
|
||||
->where('todo_id', $todoId)
|
||||
->where('category_id', $categoryId)
|
||||
->delete();
|
||||
|
||||
return $this->successResponse(null, 'Category removed from todo successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a category to a todo (internal helper)
|
||||
*/
|
||||
private function linkCategory(string $todoId, string $categoryId): void
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
|
||||
$categoryModel = new CategoryModel();
|
||||
$category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->todoCategoryModel
|
||||
->where('todo_id', $todoId)
|
||||
->where('category_id', $categoryId)
|
||||
->first();
|
||||
|
||||
if (!$existing) {
|
||||
$this->todoCategoryModel->insert([
|
||||
'todo_id' => $todoId,
|
||||
'category_id' => $categoryId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/Controllers/Api/V1/UserController.php
Normal file
126
app/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ApiAuthKeyModel;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
protected $userModel;
|
||||
protected $apiAuthKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->apiAuthKeyModel = new ApiAuthKeyModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
* GET /api/v1/user/profile
|
||||
*/
|
||||
public function profile()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
return $this->errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
// Remove sensitive data
|
||||
unset($user['password_hash']);
|
||||
|
||||
return $this->successResponse($user, 'Profile retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
* PUT /api/v1/user/profile
|
||||
*/
|
||||
public function updateProfile()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$allowedFields = ['name', 'avatar_url', 'settings'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->userModel->update($userId, $updateData);
|
||||
$user = $this->userModel->find($userId);
|
||||
unset($user['password_hash']);
|
||||
|
||||
return $this->successResponse($user, 'Profile updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's API keys
|
||||
* GET /api/v1/user/api-keys
|
||||
*/
|
||||
public function listApiKeys()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$apiKeys = $this->apiAuthKeyModel->getByUser($userId);
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($apiKeys as &$key) {
|
||||
unset($key['key_hash']);
|
||||
}
|
||||
|
||||
return $this->successResponse($apiKeys, 'API keys retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* POST /api/v1/user/api-keys
|
||||
*/
|
||||
public function createApiKey()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$name = $json['name'] ?? 'API Key';
|
||||
$scopes = $json['scopes'] ?? ['read', 'write'];
|
||||
$expiresAt = $json['expires_at'] ?? null;
|
||||
|
||||
$apiKey = $this->apiAuthKeyModel->createKey(
|
||||
$userId,
|
||||
$name,
|
||||
$scopes,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
return $this->successResponse([
|
||||
'id' => $apiKey['id'],
|
||||
'key' => $apiKey['key'],
|
||||
'prefix' => $apiKey['prefix'],
|
||||
'name' => $apiKey['name'],
|
||||
'scopes' => $apiKey['scopes'],
|
||||
'expires_at' => $apiKey['expires_at'],
|
||||
], 'API key created successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
* DELETE /api/v1/user/api-keys/{id}
|
||||
*/
|
||||
public function revokeApiKey($id)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$apiKey = $this->apiAuthKeyModel->find($id);
|
||||
|
||||
if (!$apiKey || $apiKey['user_id'] !== $userId) {
|
||||
return $this->errorResponse('API key not found', 404);
|
||||
}
|
||||
|
||||
$this->apiAuthKeyModel->revokeKey($id);
|
||||
|
||||
return $this->successResponse(null, 'API key revoked successfully');
|
||||
}
|
||||
}
|
||||
110
app/Controllers/Api/V1/UserThemeController.php
Normal file
110
app/Controllers/Api/V1/UserThemeController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api\V1;
|
||||
|
||||
use App\Controllers\Api\BaseController;
|
||||
use App\Models\UserThemeModel;
|
||||
|
||||
class UserThemeController extends BaseController
|
||||
{
|
||||
protected $userThemeModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userThemeModel = new UserThemeModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all themes for the authenticated user
|
||||
* GET /api/v1/user/themes
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$themes = $this->userThemeModel->getByUser($userId);
|
||||
|
||||
return $this->successResponse($themes, 'User themes retrieved successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user theme
|
||||
* POST /api/v1/user/themes
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$json = $this->request->getJSON(true);
|
||||
|
||||
$rules = [
|
||||
'theme_id' => 'required',
|
||||
];
|
||||
|
||||
if (!$this->validateRequest($rules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'theme_id' => $json['theme_id'],
|
||||
'is_active' => $json['is_active'] ?? false,
|
||||
'custom_settings' => $json['custom_settings'] ? json_encode($json['custom_settings']) : null,
|
||||
];
|
||||
|
||||
$this->userThemeModel->insert($data);
|
||||
$theme = $this->userThemeModel->find($data['id']);
|
||||
|
||||
return $this->successResponse($theme, 'User theme created successfully', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user theme
|
||||
* PUT /api/v1/user/themes/{id}
|
||||
*/
|
||||
public function update($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('User theme not found', 404);
|
||||
}
|
||||
|
||||
$json = $this->request->getJSON(true);
|
||||
$allowedFields = ['is_active', 'custom_settings'];
|
||||
$updateData = array_intersect_key($json, array_flip($allowedFields));
|
||||
|
||||
if (isset($updateData['custom_settings'])) {
|
||||
$updateData['custom_settings'] = json_encode($updateData['custom_settings']);
|
||||
}
|
||||
|
||||
if (empty($updateData)) {
|
||||
return $this->errorResponse('No valid fields to update');
|
||||
}
|
||||
|
||||
$this->userThemeModel->update($id, $updateData);
|
||||
$theme = $this->userThemeModel->find($id);
|
||||
|
||||
return $this->successResponse($theme, 'User theme updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user theme
|
||||
* DELETE /api/v1/user/themes/{id}
|
||||
*/
|
||||
public function delete($id = null)
|
||||
{
|
||||
$userId = $this->getUserId();
|
||||
$theme = $this->userThemeModel->where('id', $id)->where('user_id', $userId)->first();
|
||||
|
||||
if (!$theme) {
|
||||
return $this->errorResponse('User theme not found', 404);
|
||||
}
|
||||
|
||||
$this->userThemeModel->delete($id);
|
||||
|
||||
return $this->successResponse(null, 'User theme deleted successfully');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
268
app/Controllers/ThemeStore.php
Normal file
268
app/Controllers/ThemeStore.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\MarketplaceThemeModel;
|
||||
use App\Models\UserThemeModel;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
|
||||
class ThemeStore extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$themes = $model->where('is_published', 1)->findAll();
|
||||
|
||||
foreach ($themes as &$theme) {
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$theme['colors'] = $meta['colors'] ?? [];
|
||||
$theme['tags'] = $meta['tags'] ?? [];
|
||||
$theme['vars'] = $meta['vars'] ?? [];
|
||||
|
||||
// Provide a preview array compatible with the frontend
|
||||
$theme['preview'] = !empty($theme['colors']) ? array_values($theme['colors']) : ['#ffffff', '#f0f0f0', '#007acc'];
|
||||
}
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
||||
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
return $this->response->setJSON($themes);
|
||||
}
|
||||
|
||||
return view('theme_store', [
|
||||
'themes' => $themes,
|
||||
'flash_success' => session()->getFlashdata('success'),
|
||||
'flash_error' => session()->getFlashdata('error'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): Response
|
||||
{
|
||||
$origin = $this->request->getHeaderLine('Origin') ?: '*';
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
|
||||
$file = $this->request->getFile('theme_css');
|
||||
$displayName = trim($this->request->getPost('display_name') ?? '');
|
||||
$description = trim($this->request->getPost('description') ?? '');
|
||||
|
||||
if ($displayName === '') {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Display name is required.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Display name is required.');
|
||||
}
|
||||
|
||||
if (! $file || ! $file->isValid() || $file->hasMoved()) {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Please upload a valid CSS file.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Please upload a valid CSS file.');
|
||||
}
|
||||
|
||||
if (strtolower($file->getExtension()) !== 'css') {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Only .css files are allowed.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Only .css files are allowed.');
|
||||
}
|
||||
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $displayName));
|
||||
$slug = trim($slug, '-');
|
||||
$filename = $slug . '-' . substr(bin2hex(random_bytes(3)), 0, 6) . '.css';
|
||||
|
||||
$file->move(FCPATH . 'themes', $filename, true);
|
||||
|
||||
// Extract CSS variables and colors from the uploaded file
|
||||
$cssContent = file_get_contents(FCPATH . 'themes/' . $filename);
|
||||
preg_match_all('/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/', $cssContent, $matches);
|
||||
|
||||
$vars = [];
|
||||
if (!empty($matches[1])) {
|
||||
foreach ($matches[1] as $index => $key) {
|
||||
$vars[$key] = trim($matches[2][$index]);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to generate 3-color preview based on standard variables
|
||||
$colors = [];
|
||||
if (isset($vars['--bg'])) $colors['bg'] = $vars['--bg'];
|
||||
if (isset($vars['--surface'])) $colors['surface'] = $vars['--surface'];
|
||||
if (isset($vars['--accent'])) $colors['accent'] = $vars['--accent'];
|
||||
|
||||
$model = new MarketplaceThemeModel();
|
||||
$model->insert([
|
||||
'id' => $this->uuid4(),
|
||||
'name' => $slug,
|
||||
'display_name' => $displayName,
|
||||
'description' => $description ?: 'Custom community theme.',
|
||||
'author' => 'Community',
|
||||
'version' => '1.0.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/' . $filename,
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['custom', 'community'],
|
||||
'colors' => $colors,
|
||||
'vars' => $vars
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => '"' . esc($displayName) . '" uploaded successfully!'
|
||||
]);
|
||||
}
|
||||
return redirect()->to('themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!');
|
||||
}
|
||||
|
||||
public function preview(string $id): Response
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$theme = $model->find($id);
|
||||
|
||||
if (! $theme) {
|
||||
return $this->response->setStatusCode(404)->setBody('<p style="font-family:sans-serif;padding:2rem">Theme not found.</p>');
|
||||
}
|
||||
|
||||
$distIndex = '/home/came/Nextcloud/arch-work/Projects/Todo-App/dist/index.html';
|
||||
|
||||
if (! file_exists($distIndex)) {
|
||||
return $this->response->setBody(
|
||||
'<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f0f17;color:#94a3b8">'
|
||||
. '<div style="text-align:center"><p>Todo app dist not found.</p></div></body></html>'
|
||||
);
|
||||
}
|
||||
|
||||
$todoHtml = file_get_contents($distIndex);
|
||||
|
||||
// Rewrite asset paths from /assets/ to the public symlink so Apache serves them
|
||||
$assetBase = rtrim(base_url('todo-preview'), '/');
|
||||
$todoHtml = str_replace('="/assets/', '="' . $assetBase . '/assets/', $todoHtml);
|
||||
|
||||
// Build CSS variable overrides from the stored vars map
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$vars = $meta['vars'] ?? [];
|
||||
|
||||
$cssVars = ":root {\n";
|
||||
foreach ($vars as $prop => $value) {
|
||||
$cssVars .= " {$prop}: {$value};\n";
|
||||
}
|
||||
$cssVars .= "}\n";
|
||||
|
||||
// Also inject any raw CSS from the downloaded file (for custom/uploaded themes)
|
||||
$cssPath = FCPATH . ltrim($theme['download_url'], '/');
|
||||
$rawCss = file_exists($cssPath) ? file_get_contents($cssPath) : '';
|
||||
|
||||
$styleTag = "<style>\n/* Theme Store: {$theme['display_name']} */\n{$cssVars}\n{$rawCss}\n</style>";
|
||||
|
||||
$todoHtml = str_replace('</head>', $styleTag . "\n</head>", $todoHtml);
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
->setBody($todoHtml);
|
||||
}
|
||||
|
||||
public function install(string $id): Response
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$theme = $model->find($id);
|
||||
|
||||
if (! $theme) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['error' => 'Theme not found in the marketplace.']);
|
||||
}
|
||||
|
||||
// Using session user_id or a default placeholder since standard auth might be configured separately
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||
$userThemeModel->installTheme($userId, $id);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => '"' . esc($theme['display_name']) . '" has been installed to your account.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function activate(string $id): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Theme must be installed before it can be activated.']);
|
||||
}
|
||||
|
||||
$userThemeModel->setActiveTheme($userId, $id);
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => 'Theme activated successfully.']);
|
||||
}
|
||||
|
||||
public function uninstall(string $id): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
$userThemeModel->uninstallTheme($userId, $id);
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => 'Theme successfully uninstalled.']);
|
||||
}
|
||||
|
||||
public function myThemes(): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'data' => $userThemeModel->getUserThemes($userId)]);
|
||||
}
|
||||
|
||||
public function serveCss(string $filename): Response
|
||||
{
|
||||
// Ensure it's just a file name (prevent directory traversal)
|
||||
$filename = basename($filename);
|
||||
$cssPath = FCPATH . 'themes/' . $filename;
|
||||
|
||||
// If the file actually exists on disk (e.g. newly uploaded themes)
|
||||
if (file_exists($cssPath)) {
|
||||
$css = file_get_contents($cssPath);
|
||||
return $this->response->setContentType('text/css')->setBody($css);
|
||||
}
|
||||
|
||||
// Generate dynamically for seeded themes that don't have a physical file
|
||||
$model = new MarketplaceThemeModel();
|
||||
$name = preg_replace('/\.css$/i', '', $filename);
|
||||
$theme = $model->where('name', $name)->first();
|
||||
|
||||
if (! $theme) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$vars = $meta['vars'] ?? [];
|
||||
|
||||
$css = "/* Theme: {$theme['display_name']} */\n:root {\n";
|
||||
foreach ($vars as $prop => $value) {
|
||||
$css .= " {$prop}: {$value};\n";
|
||||
}
|
||||
$css .= "}\n";
|
||||
|
||||
return $this->response->setContentType('text/css')->setBody($css);
|
||||
}
|
||||
|
||||
private function uuid4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateApiAuthKeysTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 36,
|
||||
'null' => false,
|
||||
],
|
||||
'user_id' => [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 36,
|
||||
'null' => false,
|
||||
],
|
||||
'key_hash' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => false,
|
||||
'comment' => 'SHA-256 hash of the API key',
|
||||
],
|
||||
'key_prefix' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'null' => false,
|
||||
'comment' => 'First 8 characters for identification',
|
||||
],
|
||||
'name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
'comment' => 'User-friendly name for the key',
|
||||
],
|
||||
'scopes' => [
|
||||
'type' => 'JSON',
|
||||
'null' => true,
|
||||
'comment' => 'Array of allowed scopes (e.g., ["read", "write"])',
|
||||
],
|
||||
'expires_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
'comment' => 'Optional expiration date',
|
||||
],
|
||||
'last_used_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'last_used_ip' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
'comment' => 'IPv4 or IPv6 address',
|
||||
],
|
||||
'is_active' => [
|
||||
'type' => 'BOOLEAN',
|
||||
'default' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('user_id');
|
||||
$this->forge->addKey('key_hash');
|
||||
$this->forge->addKey('is_active');
|
||||
$this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE');
|
||||
$this->forge->createTable('api_auth_keys');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('api_auth_keys');
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,296 @@ class MarketplaceThemesSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS=0');
|
||||
$this->db->table('marketplace_themes')->truncate();
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS=1');
|
||||
|
||||
$data = [
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440010',
|
||||
'name' => 'default-light',
|
||||
'display_name' => 'Default Light',
|
||||
'description' => 'Clean and simple light theme',
|
||||
'author' => 'System',
|
||||
'version' => '1.0.0',
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440010',
|
||||
'name' => 'ocean-breeze',
|
||||
'display_name' => 'Ocean Breeze',
|
||||
'description' => 'A refreshing light theme inspired by the open sea. Soft teals and ocean blues create a calm, productive workspace that\'s easy on the eyes during long work sessions.',
|
||||
'author' => 'ThemeForge',
|
||||
'version' => '1.2.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/default-light.zip',
|
||||
'price' => 0,
|
||||
'download_url' => '/themes/ocean-breeze.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode(['tags' => ['light', 'clean']]),
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'blue', 'calm', 'minimal'],
|
||||
'colors' => [
|
||||
'Primary' => '#0077B6',
|
||||
'Secondary' => '#00B4D8',
|
||||
'Background' => '#E0F4FF',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1A2B3C',
|
||||
'Accent' => '#48CAE4',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#E0F4FF',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F0F9FF',
|
||||
'--border' => '#BAE0F2',
|
||||
'--line' => '#90C8E0',
|
||||
'--text' => '#1A2B3C',
|
||||
'--text-muted' => '#4A6B7A',
|
||||
'--text-strong' => '#0D1B26',
|
||||
'--accent' => '#0077B6',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#CCE9F5',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#BAE0F2',
|
||||
'--sidebar-text' => '#1A2B3C',
|
||||
'--sidebar-text-muted' => '#4A6B7A',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#BAE0F2',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#C8E8F0',
|
||||
'--success' => '#D4F0E4',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440011',
|
||||
'name' => 'default-dark',
|
||||
'display_name' => 'Default Dark',
|
||||
'description' => 'Dark theme for night owls',
|
||||
'author' => 'System',
|
||||
'version' => '1.0.0',
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440011',
|
||||
'name' => 'midnight-void',
|
||||
'display_name' => 'Midnight Void',
|
||||
'description' => 'Deep space dark theme for night owls and late-night coders. Rich dark purples and blues with vibrant neon accents give this theme a premium, modern feel.',
|
||||
'author' => 'ThemeForge',
|
||||
'version' => '2.0.1',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/default-dark.zip',
|
||||
'price' => 0,
|
||||
'download_url' => '/themes/midnight-void.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode(['tags' => ['dark', 'night']]),
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['dark', 'purple', 'neon', 'night'],
|
||||
'colors' => [
|
||||
'Primary' => '#7C3AED',
|
||||
'Secondary' => '#A78BFA',
|
||||
'Background' => '#0D0D1A',
|
||||
'Surface' => '#1A1A2E',
|
||||
'Text' => '#E2E8F0',
|
||||
'Accent' => '#F472B6',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#0D0D1A',
|
||||
'--surface' => '#1A1A2E',
|
||||
'--surface-strong' => '#222234',
|
||||
'--surface-muted' => '#121220',
|
||||
'--border' => '#2A2A44',
|
||||
'--line' => '#333350',
|
||||
'--text' => '#E2E8F0',
|
||||
'--text-muted' => '#94A3B8',
|
||||
'--text-strong' => '#F1F5F9',
|
||||
'--accent' => '#7C3AED',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#2D1A5E',
|
||||
'--sidebar-bg' => '#16162A',
|
||||
'--sidebar-border' => '#2A2A44',
|
||||
'--sidebar-text' => '#E2E8F0',
|
||||
'--sidebar-text-muted' => '#94A3B8',
|
||||
'--input-bg' => '#0D0D1A',
|
||||
'--input-border' => '#2A2A44',
|
||||
'--modal-bg' => '#1A1A2E',
|
||||
'--chip' => '#2A2A44',
|
||||
'--success' => '#0D2A1A',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440012',
|
||||
'name' => 'forest-grove',
|
||||
'display_name' => 'Forest Grove',
|
||||
'description' => 'Earthy greens and warm neutrals bring the tranquility of a woodland retreat to your workspace. A grounding, nature-inspired theme designed for focused productivity.',
|
||||
'author' => 'NaturePalette',
|
||||
'version' => '1.0.5',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/forest-grove.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'green', 'earthy', 'nature'],
|
||||
'colors' => [
|
||||
'Primary' => '#2D6A4F',
|
||||
'Secondary' => '#52B788',
|
||||
'Background' => '#F0F7EE',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1B2E22',
|
||||
'Accent' => '#B7E4C7',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#F0F7EE',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F5FAF4',
|
||||
'--border' => '#C0DACB',
|
||||
'--line' => '#A0C4B0',
|
||||
'--text' => '#1B2E22',
|
||||
'--text-muted' => '#527A62',
|
||||
'--text-strong' => '#0D1F14',
|
||||
'--accent' => '#2D6A4F',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#C0E8D4',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#C0DACB',
|
||||
'--sidebar-text' => '#1B2E22',
|
||||
'--sidebar-text-muted' => '#527A62',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#C0DACB',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#B8E0C8',
|
||||
'--success' => '#CCF0DC',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440013',
|
||||
'name' => 'sunset-ember',
|
||||
'display_name' => 'Sunset Ember',
|
||||
'description' => 'Warm oranges, deep reds, and golden highlights capture the magic of a perfect sunset. This vibrant theme adds energy and warmth to every interaction.',
|
||||
'author' => 'ChromaCraft',
|
||||
'version' => '1.1.2',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/sunset-ember.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['warm', 'orange', 'vibrant', 'sunset'],
|
||||
'colors' => [
|
||||
'Primary' => '#D62828',
|
||||
'Secondary' => '#F77F00',
|
||||
'Background' => '#FFF5E4',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#2D1B00',
|
||||
'Accent' => '#FCBF49',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#FFF5E4',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#FFF8F0',
|
||||
'--border' => '#F0D0A8',
|
||||
'--line' => '#E0B880',
|
||||
'--text' => '#2D1B00',
|
||||
'--text-muted' => '#8A6040',
|
||||
'--text-strong' => '#1A0A00',
|
||||
'--accent' => '#D62828',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#FFE0CC',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#F0D0A8',
|
||||
'--sidebar-text' => '#2D1B00',
|
||||
'--sidebar-text-muted' => '#8A6040',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#F0D0A8',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#F8D8B0',
|
||||
'--success' => '#DDFADC',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440014',
|
||||
'name' => 'arctic-frost',
|
||||
'display_name' => 'Arctic Frost',
|
||||
'description' => 'Ultra-clean whites and icy blues inspired by frozen tundras. A minimalist theme that maximises clarity and focus with crisp contrast and breathable spacing.',
|
||||
'author' => 'MinimalStudio',
|
||||
'version' => '3.0.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/arctic-frost.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'minimal', 'clean', 'ice'],
|
||||
'colors' => [
|
||||
'Primary' => '#2176AE',
|
||||
'Secondary' => '#57C4E5',
|
||||
'Background' => '#F8FBFF',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1C2B3A',
|
||||
'Accent' => '#A8DADC',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#F8FBFF',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F0F5FC',
|
||||
'--border' => '#C0D4E8',
|
||||
'--line' => '#A0BCDA',
|
||||
'--text' => '#1C2B3A',
|
||||
'--text-muted' => '#4E6478',
|
||||
'--text-strong' => '#0D1B2A',
|
||||
'--accent' => '#2176AE',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#CCE0F0',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#C0D4E8',
|
||||
'--sidebar-text' => '#1C2B3A',
|
||||
'--sidebar-text-muted' => '#4E6478',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#C0D4E8',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#B8D4E8',
|
||||
'--success' => '#D4F0E4',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440015',
|
||||
'name' => 'obsidian-rose',
|
||||
'display_name' => 'Obsidian Rose',
|
||||
'description' => 'A sophisticated dark theme blending deep charcoal blacks with rose gold accents. Elegant and bold, this theme is built for those who want style without sacrificing readability.',
|
||||
'author' => 'ChromaCraft',
|
||||
'version' => '1.3.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/obsidian-rose.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['dark', 'elegant', 'rose', 'premium'],
|
||||
'colors' => [
|
||||
'Primary' => '#C9184A',
|
||||
'Secondary' => '#FF4D6D',
|
||||
'Background' => '#0A0A0F',
|
||||
'Surface' => '#1C1C28',
|
||||
'Text' => '#F1E3E4',
|
||||
'Accent' => '#B5838D',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#0A0A0F',
|
||||
'--surface' => '#1C1C28',
|
||||
'--surface-strong' => '#242430',
|
||||
'--surface-muted' => '#14141E',
|
||||
'--border' => '#2A2A38',
|
||||
'--line' => '#383848',
|
||||
'--text' => '#F1E3E4',
|
||||
'--text-muted' => '#B5939A',
|
||||
'--text-strong' => '#FAF0F1',
|
||||
'--accent' => '#C9184A',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#3D0A1A',
|
||||
'--sidebar-bg' => '#161620',
|
||||
'--sidebar-border' => '#2A2A38',
|
||||
'--sidebar-text' => '#F1E3E4',
|
||||
'--sidebar-text-muted' => '#B5939A',
|
||||
'--input-bg' => '#0A0A0F',
|
||||
'--input-border' => '#2A2A38',
|
||||
'--modal-bg' => '#1C1C28',
|
||||
'--chip' => '#2A2030',
|
||||
'--success' => '#0A2016',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
|
||||
@@ -309,5 +309,38 @@ class SampleDataSeeder extends Seeder
|
||||
if (!empty($recurringTaskCategories)) {
|
||||
$this->db->table('recurring_task_categories')->insertBatch($recurringTaskCategories);
|
||||
}
|
||||
|
||||
// Create an API key for the demo user
|
||||
$existingApiKey = $this->db->table('api_auth_keys')
|
||||
->where('user_id', $userId)
|
||||
->where('name', 'Demo API Key')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$existingApiKey) {
|
||||
$apiKey = 'todo_' . bin2hex(random_bytes(32));
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
$keyPrefix = substr($apiKey, 0, 8);
|
||||
|
||||
$this->db->table('api_auth_keys')->insert([
|
||||
'id' => $generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => 'Demo API Key',
|
||||
'scopes' => json_encode(['read', 'write']),
|
||||
'expires_at' => null,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
echo "\n========================================\n";
|
||||
echo "DEMO API KEY CREATED:\n";
|
||||
echo "========================================\n";
|
||||
echo "API Key: {$apiKey}\n";
|
||||
echo "Prefix: {$keyPrefix}\n";
|
||||
echo "Use this key in the X-API-Key header\n";
|
||||
echo "========================================\n\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
app/Filters/ApiAuthFilter.php
Normal file
100
app/Filters/ApiAuthFilter.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class ApiAuthFilter 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 mixed
|
||||
*/
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$apiKey = $request->getHeaderLine('X-API-Key');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return $this->unauthorized('API key is required');
|
||||
}
|
||||
|
||||
$apiAuthKeyModel = new \App\Models\ApiAuthKeyModel();
|
||||
$result = $apiAuthKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$result) {
|
||||
return $this->unauthorized('Invalid or expired API key');
|
||||
}
|
||||
|
||||
// Store the authenticated user in the request
|
||||
$request->user = $result['user'];
|
||||
$request->authKey = $result['auth_key'];
|
||||
|
||||
// Check scopes if required
|
||||
if (!empty($arguments)) {
|
||||
$requiredScopes = $arguments;
|
||||
$keyScopes = $result['auth_key']['scopes'] ? json_decode($result['auth_key']['scopes'], true) : [];
|
||||
|
||||
if (empty($keyScopes)) {
|
||||
// No scopes defined, allow all
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (!in_array($scope, $keyScopes)) {
|
||||
return $this->forbidden('Insufficient permissions. Required scope: ' . $scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't need to do anything here.
|
||||
*
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param array|null $arguments
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Return unauthorized response
|
||||
*/
|
||||
private function unauthorized(string $message): ResponseInterface
|
||||
{
|
||||
$response = \Config\Services::response();
|
||||
return $response->setStatusCode(401)->setJSON([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return forbidden response
|
||||
*/
|
||||
private function forbidden(string $message): ResponseInterface
|
||||
{
|
||||
$response = \Config\Services::response();
|
||||
return $response->setStatusCode(403)->setJSON([
|
||||
'error' => 'Forbidden',
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class ActivityLogModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'action' => 'required|max_length[255]',
|
||||
@@ -34,9 +34,6 @@ class ActivityLogModel extends Model
|
||||
// Log an activity
|
||||
public function logActivity($data)
|
||||
{
|
||||
// Disable events to prevent any recursive logging
|
||||
$this->skipEvents();
|
||||
|
||||
if (!isset($data['id'])) {
|
||||
$data['id'] = $this->generateUuid();
|
||||
}
|
||||
@@ -44,12 +41,9 @@ class ActivityLogModel extends Model
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$result = $this->insert($data);
|
||||
|
||||
// Re-enable events
|
||||
$this->skipEvents(false);
|
||||
|
||||
return $result;
|
||||
// Use builder directly to avoid triggering events
|
||||
$builder = $this->db->table($this->table);
|
||||
return $builder->insert($data);
|
||||
}
|
||||
|
||||
// Get logs by user
|
||||
|
||||
@@ -22,7 +22,7 @@ class AiMessageModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'chat_id' => 'required',
|
||||
|
||||
@@ -22,7 +22,7 @@ class AiProviderModel extends Model
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'name' => 'required|max_length[100]|is_unique[ai_providers.name]',
|
||||
|
||||
164
app/Models/ApiAuthKeyModel.php
Normal file
164
app/Models/ApiAuthKeyModel.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ApiAuthKeyModel extends Model
|
||||
{
|
||||
protected $table = 'api_auth_keys';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'key_hash',
|
||||
'key_prefix',
|
||||
'name',
|
||||
'scopes',
|
||||
'expires_at',
|
||||
'last_used_at',
|
||||
'last_used_ip',
|
||||
'is_active',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'key_hash' => 'required',
|
||||
'key_prefix' => 'required|max_length[20]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
public function generateKey(): string
|
||||
{
|
||||
return 'todo_' . bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key for a user
|
||||
*/
|
||||
public function createKey(string $userId, ?string $name = null, ?array $scopes = null, ?string $expiresAt = null): array
|
||||
{
|
||||
$key = $this->generateKey();
|
||||
$keyHash = hash('sha256', $key);
|
||||
$keyPrefix = substr($key, 0, 8);
|
||||
|
||||
$data = [
|
||||
'id' => $this->generateUuid(),
|
||||
'user_id' => $userId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'scopes' => $scopes ? json_encode($scopes) : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$this->insert($data);
|
||||
|
||||
return [
|
||||
'id' => $data['id'],
|
||||
'key' => $key,
|
||||
'prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'scopes' => $scopes,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key and return the associated user
|
||||
*/
|
||||
public function validateKey(string $key): ?array
|
||||
{
|
||||
$keyHash = hash('sha256', $key);
|
||||
|
||||
$authKey = $this->where('key_hash', $keyHash)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$authKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if key has expired
|
||||
if ($authKey['expires_at'] && strtotime($authKey['expires_at']) < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used information
|
||||
$this->update($authKey['id'], [
|
||||
'last_used_at' => date('Y-m-d H:i:s'),
|
||||
'last_used_ip' => $this->getClientIp(),
|
||||
]);
|
||||
|
||||
// Get the user
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($authKey['user_id']);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'auth_key' => $authKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys for a user
|
||||
*/
|
||||
public function getByUser(string $userId): array
|
||||
{
|
||||
return $this->where('user_id', $userId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
*/
|
||||
public function revokeKey(string $keyId): bool
|
||||
{
|
||||
return $this->update($keyId, ['is_active' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
private function getClientIp(): ?string
|
||||
{
|
||||
try {
|
||||
$request = \Config\Services::request();
|
||||
return $request->getIPAddress();
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID
|
||||
*/
|
||||
private function generateUuid(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,33 +6,38 @@ use CodeIgniter\Model;
|
||||
|
||||
class CategoryModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'categories';
|
||||
protected $primaryKey = 'id';
|
||||
protected $table = 'categories';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'color',
|
||||
'favorite',
|
||||
'created_at',
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id', 'user_id', 'name', 'color', 'favorite', 'created_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'name' => 'required|max_length[255]',
|
||||
'user_id' => [
|
||||
'rules' => 'required',
|
||||
'errors' => ['required' => 'User ID is required.'],
|
||||
],
|
||||
'name' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'The category name is required.',
|
||||
'max_length' => 'The category name must not exceed 255 characters.',
|
||||
],
|
||||
],
|
||||
'color' => [
|
||||
'rules' => 'required|max_length[7]|regex_match[/^#[0-9a-fA-F]{6}$/]',
|
||||
'errors' => [
|
||||
'required' => 'A color value is required.',
|
||||
'max_length' => 'Color must be a hex code (e.g. #3B82F6).',
|
||||
'regex_match' => 'Color must be a valid hex code (e.g. #3B82F6).',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'category';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ trait LoggableTrait
|
||||
{
|
||||
try {
|
||||
$request = \Config\Services::request();
|
||||
return $request->getUserAgent()->toString();
|
||||
return $request->getUserAgent()->getAgentString();
|
||||
} catch (\Exception $e) {
|
||||
return 'CLI/Script';
|
||||
}
|
||||
|
||||
@@ -6,33 +6,30 @@ use CodeIgniter\Model;
|
||||
|
||||
class ProjectModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'projects';
|
||||
protected $primaryKey = 'id';
|
||||
protected $table = 'projects';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
'color',
|
||||
'created_at',
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id', 'user_id', 'name', 'description', 'color', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = null;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = '';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'name' => 'required|max_length[255]',
|
||||
'user_id' => [
|
||||
'rules' => 'required',
|
||||
'errors' => ['required' => 'User ID is required.'],
|
||||
],
|
||||
'name' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'The project name is required.',
|
||||
'max_length' => 'The project name must not exceed 255 characters.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'project';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,47 +6,53 @@ use CodeIgniter\Model;
|
||||
|
||||
class RecurringTaskModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'recurring_tasks';
|
||||
protected $primaryKey = 'id';
|
||||
protected $table = 'recurring_tasks';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'title',
|
||||
'description',
|
||||
'schedule',
|
||||
'custom_days',
|
||||
'favorite',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id', 'user_id', 'title', 'description', 'schedule',
|
||||
'custom_days', 'favorite', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'title' => 'required|max_length[255]',
|
||||
'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
|
||||
'user_id' => [
|
||||
'rules' => 'required',
|
||||
'errors' => ['required' => 'User ID is required.'],
|
||||
],
|
||||
'title' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'The recurring task title is required.',
|
||||
'max_length' => 'The title must not exceed 255 characters.',
|
||||
],
|
||||
],
|
||||
'schedule' => [
|
||||
'rules' => 'permit_empty|in_list[daily,weekly,monthly,custom]',
|
||||
'errors' => [
|
||||
'in_list' => 'Schedule must be one of: daily, weekly, monthly, custom.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'recurring_task';
|
||||
}
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
|
||||
// Get recurring tasks with categories
|
||||
public function getWithCategories($taskId = null)
|
||||
public function getByUserWithCategories($userId, $taskId = null)
|
||||
{
|
||||
$builder = $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
|
||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||
->groupBy('recurring_tasks.id');
|
||||
$builder = $this->select('
|
||||
recurring_tasks.*,
|
||||
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
|
||||
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
|
||||
')
|
||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||
->where('recurring_tasks.user_id', $userId)
|
||||
->groupBy('recurring_tasks.id');
|
||||
|
||||
if ($taskId) {
|
||||
$builder->where('recurring_tasks.id', $taskId);
|
||||
@@ -54,16 +60,4 @@ class RecurringTaskModel extends Model
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
// Get recurring tasks by user with categories
|
||||
public function getByUserWithCategories($userId)
|
||||
{
|
||||
return $this->select('recurring_tasks.*, GROUP_CONCAT(categories.name) as category_names')
|
||||
->join('recurring_task_categories', 'recurring_tasks.id = recurring_task_categories.recurring_task_id', 'left')
|
||||
->join('categories', 'recurring_task_categories.category_id = categories.id', 'left')
|
||||
->where('recurring_tasks.user_id', $userId)
|
||||
->groupBy('recurring_tasks.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,51 +6,62 @@ use CodeIgniter\Model;
|
||||
|
||||
class TodoModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'todos';
|
||||
protected $primaryKey = 'id';
|
||||
protected $table = 'todos';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'user_id',
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'due_date',
|
||||
'due_time',
|
||||
'sync_enabled',
|
||||
'reminder_enabled',
|
||||
'recurring_enabled',
|
||||
'project_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id', 'user_id', 'title', 'description', 'status',
|
||||
'due_date', 'due_time', 'sync_enabled', 'reminder_enabled',
|
||||
'recurring_enabled', 'project_id', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
protected $validationRules = [
|
||||
'user_id' => 'required',
|
||||
'title' => 'required|max_length[255]',
|
||||
'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
||||
'user_id' => [
|
||||
'rules' => 'required',
|
||||
'errors' => ['required' => 'User ID is required.'],
|
||||
],
|
||||
'title' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'The todo title is required.',
|
||||
'max_length' => 'The title must not exceed 255 characters.',
|
||||
],
|
||||
],
|
||||
'status' => [
|
||||
'rules' => 'permit_empty|in_list[open,in_progress,completed,archived]',
|
||||
'errors' => [
|
||||
'in_list' => 'Status must be one of: open, in_progress, completed, archived.',
|
||||
],
|
||||
],
|
||||
'due_date' => [
|
||||
'rules' => 'permit_empty|valid_date[Y-m-d]',
|
||||
'errors' => ['valid_date' => 'Due date must be in YYYY-MM-DD format.'],
|
||||
],
|
||||
'due_time' => [
|
||||
'rules' => 'permit_empty|valid_date[H:i:s]',
|
||||
'errors' => ['valid_date' => 'Due time must be in HH:MM format.'],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'todo';
|
||||
}
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
|
||||
// Get todos with categories
|
||||
public function getWithCategories($todoId = null)
|
||||
public function getByUserWithCategories($userId, $todoId = null)
|
||||
{
|
||||
$builder = $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
|
||||
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||
->groupBy('todos.id');
|
||||
$builder = $this->select('
|
||||
todos.*,
|
||||
GROUP_CONCAT(DISTINCT categories.id SEPARATOR \',\') as category_ids,
|
||||
GROUP_CONCAT(DISTINCT categories.name SEPARATOR \', \') as category_names
|
||||
')
|
||||
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||
->where('todos.user_id', $userId)
|
||||
->groupBy('todos.id');
|
||||
|
||||
if ($todoId) {
|
||||
$builder->where('todos.id', $todoId);
|
||||
@@ -58,16 +69,4 @@ class TodoModel extends Model
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
// Get todos by user with categories
|
||||
public function getByUserWithCategories($userId)
|
||||
{
|
||||
return $this->select('todos.*, GROUP_CONCAT(categories.name) as category_names')
|
||||
->join('todo_categories', 'todos.id = todo_categories.todo_id', 'left')
|
||||
->join('categories', 'todo_categories.category_id = categories.id', 'left')
|
||||
->where('todos.user_id', $userId)
|
||||
->groupBy('todos.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,43 +6,35 @@ use CodeIgniter\Model;
|
||||
|
||||
class UserModel extends Model
|
||||
{
|
||||
use LoggableTrait;
|
||||
|
||||
protected $table = 'users';
|
||||
protected $primaryKey = 'id';
|
||||
protected $table = 'users';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = false;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id',
|
||||
'email',
|
||||
'password_hash',
|
||||
'name',
|
||||
'avatar_url',
|
||||
'settings',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'id', 'email', 'password_hash', 'name', 'avatar_url',
|
||||
'settings', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
protected $validationRules = [
|
||||
'email' => 'required|valid_email|is_unique[users.email]',
|
||||
'password_hash' => 'required',
|
||||
];
|
||||
|
||||
protected $validationMessages = [
|
||||
'email' => [
|
||||
'required' => 'Email is required',
|
||||
'valid_email' => 'Please enter a valid email address',
|
||||
'is_unique' => 'This email is already registered',
|
||||
'rules' => 'required|valid_email|is_unique[users.email]',
|
||||
'errors' => [
|
||||
'required' => 'Email is required.',
|
||||
'valid_email' => 'Please provide a valid email address.',
|
||||
'is_unique' => 'This email is already registered.',
|
||||
],
|
||||
],
|
||||
'name' => [
|
||||
'rules' => 'required|max_length[255]',
|
||||
'errors' => [
|
||||
'required' => 'Name is required.',
|
||||
'max_length' => 'Name must not exceed 255 characters.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getEntityType(): string
|
||||
{
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
861
app/Views/theme_store.php
Normal file
861
app/Views/theme_store.php
Normal file
@@ -0,0 +1,861 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Theme Store</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f17;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 60px 24px 50px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(124,58,237,0.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
header h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, #a78bfa, #60a5fa, #f472b6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
position: relative;
|
||||
}
|
||||
header p {
|
||||
margin-top: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
position: relative;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-badge {
|
||||
background: rgba(124,58,237,0.2);
|
||||
border: 1px solid rgba(124,58,237,0.4);
|
||||
color: #a78bfa;
|
||||
padding: 4px 14px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.btn-upload-header {
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-upload-header:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Flash messages ── */
|
||||
.flash {
|
||||
max-width: 680px;
|
||||
margin: 24px auto 0;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.flash-success { background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: #34d399; }
|
||||
.flash-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #f87171; }
|
||||
|
||||
/* ── Grid ── */
|
||||
.grid-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 48px rgba(0,0,0,0.5);
|
||||
border-color: rgba(167,139,250,0.3);
|
||||
}
|
||||
.card-preview {
|
||||
height: 90px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-preview .swatch { flex: 1; transition: flex 0.3s ease; }
|
||||
.card:hover .card-preview .swatch { flex: 1.4; }
|
||||
|
||||
.card-body { padding: 22px; }
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-name { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; }
|
||||
.card-version {
|
||||
font-size: 0.75rem; color: #64748b;
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 2px 8px; border-radius: 100px;
|
||||
}
|
||||
.card-author { font-size: 0.8rem; color: #94a3b8; margin-bottom: 10px; }
|
||||
.card-desc {
|
||||
font-size: 0.88rem; color: #94a3b8; line-height: 1.55;
|
||||
margin-bottom: 14px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||
.tag {
|
||||
background: rgba(167,139,250,0.1);
|
||||
border: 1px solid rgba(167,139,250,0.25);
|
||||
color: #a78bfa;
|
||||
font-size: 0.72rem; padding: 2px 9px; border-radius: 100px; font-weight: 500;
|
||||
}
|
||||
.card-actions { display: flex; gap: 10px; }
|
||||
.btn {
|
||||
flex: 1; padding: 10px 0; border-radius: 8px; border: none;
|
||||
cursor: pointer; font-size: 0.85rem; font-weight: 600;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-download {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: #fff; text-decoration: none;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.btn-details {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.btn-details:hover { background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* ── Backdrop shared ── */
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
|
||||
/* ── Details Modal ── */
|
||||
.details-modal {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
width: 95vw;
|
||||
max-width: 1100px;
|
||||
height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes popIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* colour stripe at top */
|
||||
.modal-preview-stripe {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-preview-stripe .swatch { flex: 1; }
|
||||
|
||||
/* tab bar */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.02);
|
||||
flex-shrink: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.modal-tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
background: none;
|
||||
border-top: none; border-left: none; border-right: none;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.modal-tab:hover { color: #94a3b8; }
|
||||
.modal-tab.active { color: #a78bfa; border-bottom-color: #7c3aed; }
|
||||
|
||||
/* tab panels */
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.tab-panel {
|
||||
display: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* details panel */
|
||||
.details-panel { padding: 28px; }
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.modal-title { font-size: 1.4rem; font-weight: 800; color: #f1f5f9; }
|
||||
.modal-close {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #94a3b8;
|
||||
border-radius: 8px; width: 34px; height: 34px;
|
||||
cursor: pointer; font-size: 1.1rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.modal-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
||||
.modal-meta { font-size: 0.82rem; color: #64748b; margin-bottom: 16px; }
|
||||
.modal-desc { font-size: 0.92rem; color: #94a3b8; line-height: 1.65; margin-bottom: 22px; }
|
||||
.section-label {
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: #64748b; margin-bottom: 10px;
|
||||
}
|
||||
.colors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.color-chip { border-radius: 10px; overflow: hidden; border: 1px solid rgba(255,255,255,0.07); }
|
||||
.color-chip-swatch { height: 48px; }
|
||||
.color-chip-info { padding: 6px 8px; background: rgba(255,255,255,0.03); }
|
||||
.color-chip-name { font-size: 0.7rem; color: #94a3b8; display: block; }
|
||||
.color-chip-hex { font-size: 0.75rem; font-weight: 600; color: #e2e8f0; font-family: 'SF Mono', monospace; }
|
||||
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 24px; }
|
||||
.modal-actions { display: flex; gap: 10px; }
|
||||
.btn-download-lg {
|
||||
flex: 1; padding: 13px; font-size: 0.95rem; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: #fff; font-weight: 700; text-decoration: none;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
border: none; cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-download-lg:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* preview panel */
|
||||
.preview-panel {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.preview-panel.active { display: flex; }
|
||||
.preview-toolbar {
|
||||
padding: 10px 16px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.preview-url {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
}
|
||||
.preview-iframe-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.preview-iframe-wrap iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f0f17;
|
||||
color: #94a3b8;
|
||||
gap: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid rgba(167,139,250,0.2);
|
||||
border-top-color: #7c3aed;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Upload Modal ── */
|
||||
.upload-modal {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.upload-header {
|
||||
padding: 24px 28px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.upload-title { font-size: 1.25rem; font-weight: 700; color: #f1f5f9; }
|
||||
.upload-body { padding: 0 28px 28px; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.field input[type="text"]:focus,
|
||||
.field textarea:focus {
|
||||
border-color: rgba(124,58,237,0.5);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 80px; }
|
||||
.file-drop {
|
||||
border: 2px dashed rgba(124,58,237,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
position: relative;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.file-drop:hover,
|
||||
.file-drop.drag-over {
|
||||
border-color: rgba(124,58,237,0.7);
|
||||
background: rgba(124,58,237,0.05);
|
||||
}
|
||||
.file-drop input[type="file"] {
|
||||
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
|
||||
}
|
||||
.file-drop-icon { font-size: 1.8rem; margin-bottom: 8px; display: block; }
|
||||
.file-drop strong { color: #a78bfa; }
|
||||
.file-name-preview {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #34d399;
|
||||
display: none;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
color: #334155;
|
||||
font-size: 0.82rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.3); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Theme Store</h1>
|
||||
<p>Beautiful, ready-to-use themes for your application</p>
|
||||
<div class="header-row">
|
||||
<span class="header-badge"><?= count($themes) ?> free themes</span>
|
||||
<button class="btn-upload-header" onclick="openUploadModal()">
|
||||
<span>+</span> Upload Theme
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if ($flash_success): ?>
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||
<div class="flash flash-success">✓ <?= esc($flash_success) ?></div>
|
||||
</div>
|
||||
<?php elseif ($flash_error): ?>
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||
<div class="flash flash-error">⚠ <?= esc($flash_error) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid-wrapper">
|
||||
<div class="grid">
|
||||
<?php foreach ($themes as $theme): ?>
|
||||
<div class="card">
|
||||
<div class="card-preview">
|
||||
<?php foreach (array_values($theme['colors']) as $hex): ?>
|
||||
<div class="swatch" style="background:<?= esc($hex) ?>"></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($theme['colors'])): ?>
|
||||
<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
<span class="card-name"><?= esc($theme['display_name']) ?></span>
|
||||
<span class="card-version">v<?= esc($theme['version']) ?></span>
|
||||
</div>
|
||||
<div class="card-author">by <?= esc($theme['author']) ?></div>
|
||||
<p class="card-desc"><?= esc($theme['description']) ?></p>
|
||||
<div class="card-tags">
|
||||
<?php foreach ($theme['tags'] as $tag): ?>
|
||||
<span class="tag"><?= esc($tag) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-download" onclick="installTheme(<?= htmlspecialchars(json_encode([
|
||||
'id' => $theme['id'],
|
||||
'display_name' => $theme['display_name'],
|
||||
'description' => $theme['description'],
|
||||
'author' => $theme['author'],
|
||||
'version' => $theme['version'],
|
||||
'download_url' => $theme['download_url'],
|
||||
'colors' => $theme['colors'],
|
||||
'tags' => $theme['tags'],
|
||||
'vars' => $theme['vars'],
|
||||
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||
⇓ Install Theme
|
||||
</button>
|
||||
<button class="btn btn-details"
|
||||
onclick="openDetailsModal(<?= htmlspecialchars(json_encode([
|
||||
'id' => $theme['id'],
|
||||
'display_name' => $theme['display_name'],
|
||||
'description' => $theme['description'],
|
||||
'author' => $theme['author'],
|
||||
'version' => $theme['version'],
|
||||
'download_url' => $theme['download_url'],
|
||||
'colors' => $theme['colors'],
|
||||
'tags' => $theme['tags'],
|
||||
'vars' => $theme['vars'],
|
||||
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Details Modal ── -->
|
||||
<div class="modal-backdrop" id="details-backdrop" onclick="closeDetailsOnBackdrop(event)">
|
||||
<div class="details-modal" id="details-modal">
|
||||
|
||||
<div class="modal-preview-stripe" id="dm-stripe"></div>
|
||||
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" id="tab-details" onclick="switchTab('details')">
|
||||
✎ Details
|
||||
</button>
|
||||
<button class="modal-tab" id="tab-preview" onclick="switchTab('preview')">
|
||||
▶ Live Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Details panel -->
|
||||
<div class="tab-panel active" id="panel-details">
|
||||
<div class="details-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="dm-title"></span>
|
||||
<button class="modal-close" onclick="closeDetailsModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-meta" id="dm-meta"></div>
|
||||
<p class="modal-desc" id="dm-desc"></p>
|
||||
|
||||
<div id="dm-colors-section">
|
||||
<div class="section-label">Colour Palette</div>
|
||||
<div class="colors-grid" id="dm-colors"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin-bottom:10px">Tags</div>
|
||||
<div class="modal-tags" id="dm-tags"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
|
||||
⇓ Install Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview panel -->
|
||||
<div class="tab-panel" id="panel-preview" style="height:100%">
|
||||
<div class="preview-panel" id="preview-panel-inner">
|
||||
<div class="preview-toolbar">
|
||||
<div class="preview-dot" style="background:#ff5f57"></div>
|
||||
<div class="preview-dot" style="background:#febc2e"></div>
|
||||
<div class="preview-dot" style="background:#28c840"></div>
|
||||
<div class="preview-url" id="preview-url">localhost:5173 — with theme applied</div>
|
||||
</div>
|
||||
<div class="preview-iframe-wrap">
|
||||
<div class="preview-loading" id="preview-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading preview…</span>
|
||||
</div>
|
||||
<iframe id="preview-iframe" title="Theme Preview" onload="iframeLoaded()"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .modal-body -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload Modal ── -->
|
||||
<div class="modal-backdrop" id="upload-backdrop" onclick="closeUploadOnBackdrop(event)">
|
||||
<div class="upload-modal">
|
||||
<div class="upload-header">
|
||||
<span class="upload-title">Upload Custom Theme</span>
|
||||
<button class="modal-close" onclick="closeUploadModal()">✕</button>
|
||||
</div>
|
||||
<div class="upload-body">
|
||||
<form method="post" action="<?= site_url('themes/upload') ?>" enctype="multipart/form-data">
|
||||
|
||||
<div class="field">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="display_name" placeholder="e.g. Neon Sunset" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea name="description" placeholder="Describe your theme's mood and colours…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>CSS File *</label>
|
||||
<div class="file-drop" id="file-drop">
|
||||
<input type="file" name="theme_css" accept=".css" required onchange="previewFileName(this)">
|
||||
<span class="file-drop-icon">💾</span>
|
||||
<div>Drop your <strong>.css</strong> file here<br>or <strong>click to browse</strong></div>
|
||||
<div class="file-name-preview" id="file-name-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">↑ Upload Theme</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
© <?= date('Y') ?> Theme Store — All themes are free to use
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
let currentThemeId = null;
|
||||
let currentThemeVars = {};
|
||||
let currentThemeData = null;
|
||||
let previewLoaded = false;
|
||||
|
||||
/* ── Theme Installation ── */
|
||||
function installTheme(theme) {
|
||||
// Convert theme data to match Todo-App format
|
||||
const themeData = {
|
||||
name: theme.display_name,
|
||||
description: theme.description,
|
||||
preview: Object.values(theme.colors || {}).length ? Object.values(theme.colors) : ['#ffffff', '#f0f0f0', '#007acc'],
|
||||
vars: theme.vars || {},
|
||||
source: 'theme-store'
|
||||
};
|
||||
|
||||
console.log('Installing theme:', themeData);
|
||||
|
||||
// Send install message to parent Todo-App
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'THEME_DOWNLOAD_REQUEST',
|
||||
data: themeData
|
||||
}, '*');
|
||||
|
||||
// Show success feedback
|
||||
showInstallFeedback(theme.display_name);
|
||||
} else {
|
||||
// Fallback: redirect to the frontend with the theme data
|
||||
const installUrl = "http://localhost:5173/#theme-install:" + encodeURIComponent(JSON.stringify(themeData));
|
||||
window.location.href = installUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function installCurrentTheme() {
|
||||
if (currentThemeData) {
|
||||
installTheme(currentThemeData);
|
||||
}
|
||||
}
|
||||
|
||||
function showInstallFeedback(themeName) {
|
||||
// Create a temporary success message
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'install-feedback';
|
||||
feedback.innerHTML = `✅ "${themeName}" is being installed...`;
|
||||
feedback.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(feedback);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
feedback.style.animation = 'slideOut 0.3s ease-out';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(feedback);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Add animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
/* ── Details modal ── */
|
||||
function openDetailsModal(theme) {
|
||||
currentThemeId = theme.id;
|
||||
currentThemeVars = theme.vars || {};
|
||||
currentThemeData = theme; // Store full theme data for installation
|
||||
previewLoaded = false;
|
||||
|
||||
const colors = theme.colors || {};
|
||||
const tags = theme.tags || [];
|
||||
|
||||
document.getElementById('dm-title').textContent = theme.display_name;
|
||||
document.getElementById('dm-meta').textContent = 'by ' + theme.author + ' · v' + theme.version;
|
||||
document.getElementById('dm-desc').textContent = theme.description;
|
||||
document.getElementById('preview-url').textContent = 'localhost — ' + theme.display_name + ' applied';
|
||||
|
||||
// stripe
|
||||
const stripe = document.getElementById('dm-stripe');
|
||||
const colorValues = Object.values(colors);
|
||||
stripe.innerHTML = colorValues.length
|
||||
? colorValues.map(c => `<div class="swatch" style="background:${c}"></div>`).join('')
|
||||
: '<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>';
|
||||
|
||||
// colour chips
|
||||
const colorsSection = document.getElementById('dm-colors-section');
|
||||
const grid = document.getElementById('dm-colors');
|
||||
if (Object.keys(colors).length) {
|
||||
colorsSection.style.display = '';
|
||||
grid.innerHTML = Object.entries(colors).map(([name, hex]) => `
|
||||
<div class="color-chip">
|
||||
<div class="color-chip-swatch" style="background:${hex}"></div>
|
||||
<div class="color-chip-info">
|
||||
<span class="color-chip-name">${name}</span>
|
||||
<span class="color-chip-hex">${hex}</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
colorsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// tags
|
||||
document.getElementById('dm-tags').innerHTML =
|
||||
tags.length ? tags.map(t => `<span class="tag">${t}</span>`).join('') : '<span style="color:#475569;font-size:0.82rem">No tags</span>';
|
||||
|
||||
// reset to details tab
|
||||
switchTab('details');
|
||||
|
||||
document.getElementById('details-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDetailsModal() {
|
||||
document.getElementById('details-backdrop').classList.remove('open');
|
||||
document.getElementById('preview-iframe').src = '';
|
||||
document.getElementById('preview-loading').style.display = 'flex';
|
||||
currentThemeVars = {};
|
||||
previewLoaded = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeDetailsOnBackdrop(e) {
|
||||
if (e.target === document.getElementById('details-backdrop')) closeDetailsModal();
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
document.getElementById('panel-' + name).classList.add('active');
|
||||
|
||||
if (name === 'preview') {
|
||||
document.getElementById('preview-panel-inner').classList.add('active');
|
||||
if (!previewLoaded && currentThemeId) {
|
||||
document.getElementById('preview-loading').style.display = 'flex';
|
||||
const themeData = {
|
||||
name: currentThemeData.display_name,
|
||||
vars: currentThemeVars
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(themeData));
|
||||
document.getElementById('preview-iframe').src = 'http://localhost:5173/?__theme_preview=' + encoded + '&__preview_mode=true';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('preview-panel-inner').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function iframeLoaded() {
|
||||
previewLoaded = true;
|
||||
document.getElementById('preview-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
/* ── Upload modal ── */
|
||||
function openUploadModal() {
|
||||
document.getElementById('upload-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeUploadModal() {
|
||||
document.getElementById('upload-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
function closeUploadOnBackdrop(e) {
|
||||
if (e.target === document.getElementById('upload-backdrop')) closeUploadModal();
|
||||
}
|
||||
|
||||
function previewFileName(input) {
|
||||
const el = document.getElementById('file-name-preview');
|
||||
if (input.files && input.files[0]) {
|
||||
el.textContent = '✓ ' + input.files[0].name;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-over styling
|
||||
const drop = document.getElementById('file-drop');
|
||||
drop.addEventListener('dragover', () => drop.classList.add('drag-over'));
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
|
||||
drop.addEventListener('drop', () => drop.classList.remove('drag-over'));
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeDetailsModal(); closeUploadModal(); } });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"codeigniter4/framework": "^4.7"
|
||||
"codeigniter4/framework": "^4.7",
|
||||
"firebase/php-jwt": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9",
|
||||
|
||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
|
||||
"content-hash": "86520263c0a2df285d17beea23def54d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "codeigniter4/framework",
|
||||
@@ -83,6 +83,70 @@
|
||||
},
|
||||
"time": "2026-03-24T18:26:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v7.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/php-jwt.git",
|
||||
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"phpfastcache/phpfastcache": "^9.2",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"psr/cache": "^2.0||^3.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Firebase\\JWT\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Neuman Vong",
|
||||
"email": "neuman+pear@twilio.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Anant Narayanan",
|
||||
"email": "anant@php.net",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||
"homepage": "https://github.com/firebase/php-jwt",
|
||||
"keywords": [
|
||||
"jwt",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
|
||||
},
|
||||
"time": "2026-04-01T20:38:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laminas/laminas-escaper",
|
||||
"version": "2.18.0",
|
||||
|
||||
12
env.example
12
env.example
@@ -30,13 +30,13 @@
|
||||
# DATABASE
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# database.default.hostname = localhost
|
||||
# database.default.database = ci4
|
||||
# database.default.username = root
|
||||
# database.default.password = root
|
||||
# database.default.DBDriver = MySQLi
|
||||
database.default.hostname = localhost
|
||||
database.default.database = ci4
|
||||
database.default.username = root
|
||||
database.default.password = root
|
||||
database.default.DBDriver = MySQLi
|
||||
# database.default.DBPrefix =
|
||||
# database.default.port = 3306
|
||||
database.default.port = 3306
|
||||
|
||||
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
|
||||
# database.tests.hostname = localhost
|
||||
|
||||
2186
openapi/openapi.yaml
Normal file
2186
openapi/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
bootstrap="vendor/codeigniter4/framework/system/Test/bootstrap.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
backupGlobals="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
colors="true"
|
||||
@@ -25,6 +25,8 @@
|
||||
<testsuites>
|
||||
<testsuite name="App">
|
||||
<directory>./tests</directory>
|
||||
<exclude>./tests/database</exclude>
|
||||
<exclude>./tests/session</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<logging>
|
||||
@@ -51,13 +53,18 @@
|
||||
<!-- Directory containing the front controller (index.php) -->
|
||||
<const name="PUBLICPATH" value="./public/"/>
|
||||
<!-- Database configuration -->
|
||||
<!-- Uncomment to provide your own database for testing
|
||||
<env name="database.tests.hostname" value="localhost"/>
|
||||
<env name="database.tests.database" value="tests"/>
|
||||
<env name="database.tests.username" value="tests_user"/>
|
||||
<env name="database.tests.password" value=""/>
|
||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||
<env name="database.tests.DBPrefix" value="tests_"/>
|
||||
-->
|
||||
<!-- MySQLi test database matching the .env credentials.
|
||||
The tests group defaults to SQLite3 which is not available.
|
||||
Change the database name to a separate test DB to avoid
|
||||
overwriting live data. -->
|
||||
<env name="database.tests.hostname" value="127.0.0.1"/>
|
||||
<env name="database.tests.database" value="TodoApp"/>
|
||||
<env name="database.tests.username" value="root"/>
|
||||
<env name="database.tests.password" value=""/>
|
||||
<env name="database.tests.DBDriver" value="MySQLi"/>
|
||||
<env name="database.tests.DBPrefix" value=""/>
|
||||
<!-- Note: DBPrefix removed so queries match the migrated tables.
|
||||
CI4 default tests DBPrefix = "db_" would query db_users,
|
||||
db_todos etc. which don't exist on this database. -->
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -30,7 +30,6 @@ Options -Indexes
|
||||
# such as an image or css document, if this isn't true it sends the
|
||||
# request to the front controller, index.php
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
|
||||
|
||||
# Ensure Authorization header is passed along
|
||||
|
||||
122
public/example_login.html
Normal file
122
public/example_login.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login & Register</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Login</h1>
|
||||
<form id="loginForm">
|
||||
<div>
|
||||
<label for="loginEmail">Email:</label>
|
||||
<input type="email" id="loginEmail" name="email" required>
|
||||
</div>
|
||||
<br>
|
||||
<div>
|
||||
<label for="loginPassword">Password:</label>
|
||||
<input type="password" id="loginPassword" name="password" required>
|
||||
</div>
|
||||
<br>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<h1>Register</h1>
|
||||
<form id="registerForm">
|
||||
<div>
|
||||
<label for="regEmail">Email:</label>
|
||||
<input type="email" id="regEmail" name="email" required>
|
||||
</div>
|
||||
<br>
|
||||
<div>
|
||||
<label for="regPassword">Password:</label>
|
||||
<input type="password" id="regPassword" name="password" required>
|
||||
</div>
|
||||
<br>
|
||||
<div>
|
||||
<label for="regName">Name:</label>
|
||||
<input type="text" id="regName" name="name" required>
|
||||
</div>
|
||||
<br>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
|
||||
<h2>Response</h2>
|
||||
<pre id="response"></pre>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
|
||||
|
||||
if (data.success && data.data.api_key) {
|
||||
localStorage.setItem('apiKey', data.data.api_key);
|
||||
alert('API Key saved to localStorage: ' + data.data.api_key);
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('response').textContent = 'Error: ' + error.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('registerForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('regEmail').value;
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const name = document.getElementById('regName').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
password: password,
|
||||
name: name
|
||||
})
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
data = { error: 'Invalid JSON response', raw: text, status: response.status, statusText: response.statusText };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
data = { ...data, httpError: `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
|
||||
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
|
||||
|
||||
if (data.success && data.data.api_key) {
|
||||
localStorage.setItem('apiKey', data.data.api_key);
|
||||
alert('API Key saved to localStorage: ' + data.data.api_key);
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('response').textContent = 'Error: ' + error.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
public/themes/2341342134-1441f7.css
Normal file
38
public/themes/2341342134-1441f7.css
Normal file
File diff suppressed because one or more lines are too long
16
public/themes/arctic-frost.css
Normal file
16
public/themes/arctic-frost.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Arctic Frost Theme — MinimalStudio v3.0.0 */
|
||||
:root {
|
||||
--color-primary: #2176AE;
|
||||
--color-secondary: #57C4E5;
|
||||
--color-background: #F8FBFF;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-text: #1C2B3A;
|
||||
--color-accent: #A8DADC;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-primary); }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-secondary); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 1px 6px rgba(33,118,174,0.08); padding: 20px; border: 1px solid #DDE8F0; }
|
||||
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
38
public/themes/dine-mera-3652ad.css
Normal file
38
public/themes/dine-mera-3652ad.css
Normal file
File diff suppressed because one or more lines are too long
37
public/themes/extract-test-theme-5fae6e.css
Normal file
37
public/themes/extract-test-theme-5fae6e.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* @todo-theme-meta
|
||||
{
|
||||
"name": "Extract Test Theme",
|
||||
"id": "custom-1778676985034",
|
||||
"group": "Custom",
|
||||
"preview": [
|
||||
"#f5f5f5",
|
||||
"#ffffff",
|
||||
"#274f69"
|
||||
],
|
||||
"hasWallpaper": false
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #fafafa;
|
||||
--border: #d0d0d0;
|
||||
--line: #b9b9b5;
|
||||
--text: #1f1f1f;
|
||||
--text-muted: #686866;
|
||||
--text-strong: #111111;
|
||||
--accent: #274f69;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #d6e4ec;
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-border: #d0d0d0;
|
||||
--sidebar-text: #222222;
|
||||
--sidebar-text-muted: #686866;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cfcfcf;
|
||||
--modal-bg: #ffffff;
|
||||
--chip: #d8d8d8;
|
||||
--success: #dff7e7;
|
||||
}
|
||||
16
public/themes/forest-grove.css
Normal file
16
public/themes/forest-grove.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Forest Grove Theme — NaturePalette v1.0.5 */
|
||||
:root {
|
||||
--color-primary: #2D6A4F;
|
||||
--color-secondary: #52B788;
|
||||
--color-background: #F0F7EE;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-text: #1B2E22;
|
||||
--color-accent: #B7E4C7;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-primary); }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-secondary); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(45,106,79,0.12); padding: 20px; }
|
||||
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
38
public/themes/manual-game-update-2-e1a77a.css
Normal file
38
public/themes/manual-game-update-2-e1a77a.css
Normal file
File diff suppressed because one or more lines are too long
38
public/themes/manual-game-update-7cc79d.css
Normal file
38
public/themes/manual-game-update-7cc79d.css
Normal file
File diff suppressed because one or more lines are too long
16
public/themes/midnight-void.css
Normal file
16
public/themes/midnight-void.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Midnight Void Theme — ThemeForge v2.0.1 */
|
||||
:root {
|
||||
--color-primary: #7C3AED;
|
||||
--color-secondary: #A78BFA;
|
||||
--color-background: #0D0D1A;
|
||||
--color-surface: #1A1A2E;
|
||||
--color-text: #E2E8F0;
|
||||
--color-accent: #F472B6;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-secondary); }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-accent); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(124,58,237,0.25); padding: 20px; }
|
||||
.tag { background: var(--color-accent); color: #0D0D1A; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
16
public/themes/obsidian-rose.css
Normal file
16
public/themes/obsidian-rose.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Obsidian Rose Theme — ChromaCraft v1.3.0 */
|
||||
:root {
|
||||
--color-primary: #C9184A;
|
||||
--color-secondary: #FF4D6D;
|
||||
--color-background: #0A0A0F;
|
||||
--color-surface: #1C1C28;
|
||||
--color-text: #F1E3E4;
|
||||
--color-accent: #B5838D;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-secondary); }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-secondary); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(201,24,74,0.2); padding: 20px; }
|
||||
.tag { background: var(--color-accent); color: #0A0A0F; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
16
public/themes/ocean-breeze.css
Normal file
16
public/themes/ocean-breeze.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Ocean Breeze Theme — ThemeForge v1.2.0 */
|
||||
:root {
|
||||
--color-primary: #0077B6;
|
||||
--color-secondary: #00B4D8;
|
||||
--color-background: #E0F4FF;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-text: #1A2B3C;
|
||||
--color-accent: #48CAE4;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-primary); }
|
||||
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-secondary); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,119,182,0.1); padding: 20px; }
|
||||
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
37
public/themes/red-extract-theme-a3aabe.css
Normal file
37
public/themes/red-extract-theme-a3aabe.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* @todo-theme-meta
|
||||
{
|
||||
"name": "Red Extract Theme",
|
||||
"id": "custom-1778677606575",
|
||||
"group": "Custom",
|
||||
"preview": [
|
||||
"#f5f5f5",
|
||||
"#ffffff",
|
||||
"#274f69"
|
||||
],
|
||||
"hasWallpaper": false
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #fafafa;
|
||||
--border: #d0d0d0;
|
||||
--line: #b9b9b5;
|
||||
--text: #1f1f1f;
|
||||
--text-muted: #686866;
|
||||
--text-strong: #111111;
|
||||
--accent: #274f69;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #d6e4ec;
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-border: #d0d0d0;
|
||||
--sidebar-text: #222222;
|
||||
--sidebar-text-muted: #686866;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cfcfcf;
|
||||
--modal-bg: #ffffff;
|
||||
--chip: #d8d8d8;
|
||||
--success: #dff7e7;
|
||||
}
|
||||
16
public/themes/sunset-ember.css
Normal file
16
public/themes/sunset-ember.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* Sunset Ember Theme — ChromaCraft v1.1.2 */
|
||||
:root {
|
||||
--color-primary: #D62828;
|
||||
--color-secondary: #F77F00;
|
||||
--color-background: #FFF5E4;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-text: #2D1B00;
|
||||
--color-accent: #FCBF49;
|
||||
}
|
||||
|
||||
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
|
||||
a, .link { color: var(--color-primary); }
|
||||
.btn-primary { background: var(--color-secondary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--color-primary); }
|
||||
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(214,40,40,0.1); padding: 20px; }
|
||||
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }
|
||||
37
public/themes/test-theme-103fb1.css
Normal file
37
public/themes/test-theme-103fb1.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* @todo-theme-meta
|
||||
{
|
||||
"name": "Test Theme",
|
||||
"id": "custom-1778671955013",
|
||||
"group": "Custom",
|
||||
"preview": [
|
||||
"#f5f5f5",
|
||||
"#ffffff",
|
||||
"#274f69"
|
||||
],
|
||||
"hasWallpaper": false
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #fafafa;
|
||||
--border: #d0d0d0;
|
||||
--line: #b9b9b5;
|
||||
--text: #1f1f1f;
|
||||
--text-muted: #686866;
|
||||
--text-strong: #111111;
|
||||
--accent: #274f69;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #d6e4ec;
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-border: #d0d0d0;
|
||||
--sidebar-text: #222222;
|
||||
--sidebar-text-muted: #686866;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cfcfcf;
|
||||
--modal-bg: #ffffff;
|
||||
--chip: #d8d8d8;
|
||||
--success: #dff7e7;
|
||||
}
|
||||
37
public/themes/test-theme-6fcabb.css
Normal file
37
public/themes/test-theme-6fcabb.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* @todo-theme-meta
|
||||
{
|
||||
"name": "Test Theme",
|
||||
"id": "custom-1778671955013",
|
||||
"group": "Custom",
|
||||
"preview": [
|
||||
"#f5f5f5",
|
||||
"#ffffff",
|
||||
"#274f69"
|
||||
],
|
||||
"hasWallpaper": false
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #fafafa;
|
||||
--border: #d0d0d0;
|
||||
--line: #b9b9b5;
|
||||
--text: #1f1f1f;
|
||||
--text-muted: #686866;
|
||||
--text-strong: #111111;
|
||||
--accent: #274f69;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #d6e4ec;
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-border: #d0d0d0;
|
||||
--sidebar-text: #222222;
|
||||
--sidebar-text-muted: #686866;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cfcfcf;
|
||||
--modal-bg: #ffffff;
|
||||
--chip: #d8d8d8;
|
||||
--success: #dff7e7;
|
||||
}
|
||||
37
public/themes/themestore-theme-by-came-0da6fd.css
Normal file
37
public/themes/themestore-theme-by-came-0da6fd.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* @todo-theme-meta
|
||||
{
|
||||
"name": "THemestore theme by Came",
|
||||
"id": "custom-1778674717129",
|
||||
"group": "Custom",
|
||||
"preview": [
|
||||
"#17acde",
|
||||
"#222020",
|
||||
"#00bbff"
|
||||
],
|
||||
"hasWallpaper": false
|
||||
}
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #17acde;
|
||||
--surface: #222020;
|
||||
--surface-strong: #2bc582;
|
||||
--surface-muted: #38363a;
|
||||
--border: #000000;
|
||||
--line: #ffffff;
|
||||
--text: #d12e57;
|
||||
--text-muted: #2f84c1;
|
||||
--text-strong: #ff0000;
|
||||
--accent: #00bbff;
|
||||
--accent-text: #570000;
|
||||
--accent-soft: #005f85;
|
||||
--sidebar-bg: #004370;
|
||||
--sidebar-border: #2a5070;
|
||||
--sidebar-text: #d8f0ff;
|
||||
--sidebar-text-muted: #7ab0d0;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #a0cce0;
|
||||
--modal-bg: #ffffff;
|
||||
--chip: #b0d8ec;
|
||||
--success: #d0f0e0;
|
||||
}
|
||||
38
public/themes/woiefhwoaeiglwejighfiwk-69eed7.css
Normal file
38
public/themes/woiefhwoaeiglwejighfiwk-69eed7.css
Normal file
File diff suppressed because one or more lines are too long
1
public/todo-preview
Symbolic link
1
public/todo-preview
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/came/Nextcloud/arch-work/Projects/Todo-App/dist
|
||||
251
tests/api/ModelTest.php
Normal file
251
tests/api/ModelTest.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\Test\DatabaseTestTrait;
|
||||
use App\Models\TodoModel;
|
||||
use App\Models\CategoryModel;
|
||||
use App\Models\ProjectModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* Model Unit Tests
|
||||
*
|
||||
* Tests the Todo App Backend models directly.
|
||||
* Requires a working MySQL database with migrations applied.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ModelTest extends CIUnitTestCase
|
||||
{
|
||||
use DatabaseTestTrait;
|
||||
|
||||
protected $migrate = false;
|
||||
protected $refresh = false;
|
||||
|
||||
private static string $userId = '';
|
||||
private static string $todoId = '';
|
||||
private static string $categoryId = '';
|
||||
private static string $projectId = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
// Pick the first real user from the database
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->first();
|
||||
|
||||
if ($user) {
|
||||
self::$userId = $user['id'];
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (empty(self::$userId)) {
|
||||
$this->markTestSkipped('No users in database. Run migrations and register a user first.');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TODO MODEL
|
||||
// ========================================================================
|
||||
|
||||
public function testTodoModelInsertAndFind(): void
|
||||
{
|
||||
$model = new TodoModel();
|
||||
|
||||
$data = [
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
'title' => 'Test todo',
|
||||
'status' => 'open',
|
||||
'due_date' => '2025-12-31',
|
||||
];
|
||||
|
||||
$this->assertNotFalse($model->insert($data), 'Todo insert should succeed');
|
||||
|
||||
self::$todoId = $data['id'];
|
||||
|
||||
$found = $model->find($data['id']);
|
||||
$this->assertNotNull($found, 'Todo should be findable');
|
||||
$this->assertSame('Test todo', $found['title']);
|
||||
}
|
||||
|
||||
public function testTodoModelValidation(): void
|
||||
{
|
||||
$model = new TodoModel();
|
||||
|
||||
$result = $model->insert([
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
]);
|
||||
|
||||
$this->assertFalse($result, 'Insert without title should fail');
|
||||
$this->assertNotEmpty($model->errors());
|
||||
}
|
||||
|
||||
public function testTodoModelUpdate(): void
|
||||
{
|
||||
$model = new TodoModel();
|
||||
|
||||
$updated = $model->update(self::$todoId, [
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$this->assertNotFalse($updated);
|
||||
|
||||
$found = $model->find(self::$todoId);
|
||||
$this->assertNotNull($found);
|
||||
$this->assertSame('completed', $found['status']);
|
||||
}
|
||||
|
||||
public function testTodoModelFindByUser(): void
|
||||
{
|
||||
$model = new TodoModel();
|
||||
|
||||
$results = $model->where('user_id', self::$userId)->findAll();
|
||||
$this->assertGreaterThanOrEqual(1, count($results));
|
||||
}
|
||||
|
||||
public function testTodoModelDelete(): void
|
||||
{
|
||||
$model = new TodoModel();
|
||||
$model->delete(self::$todoId);
|
||||
|
||||
$found = $model->find(self::$todoId);
|
||||
$this->assertNull($found, 'Todo should be deleted');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CATEGORY MODEL
|
||||
// ========================================================================
|
||||
|
||||
public function testCategoryModelInsertAndFind(): void
|
||||
{
|
||||
$model = new CategoryModel();
|
||||
|
||||
$data = [
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
'name' => 'Work',
|
||||
'color' => '#3B82F6',
|
||||
];
|
||||
|
||||
$this->assertNotFalse($model->insert($data));
|
||||
|
||||
self::$categoryId = $data['id'];
|
||||
|
||||
$found = $model->find($data['id']);
|
||||
$this->assertNotNull($found);
|
||||
$this->assertSame('Work', $found['name']);
|
||||
$this->assertSame('#3B82F6', $found['color']);
|
||||
}
|
||||
|
||||
public function testCategoryValidationMissingColor(): void
|
||||
{
|
||||
$model = new CategoryModel();
|
||||
|
||||
$result = $model->insert([
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
'name' => 'No Color',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testCategoryValidationInvalidColor(): void
|
||||
{
|
||||
$model = new CategoryModel();
|
||||
|
||||
$result = $model->insert([
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
'name' => 'Bad Color',
|
||||
'color' => 'not-a-hex',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testCategoryModelUpdate(): void
|
||||
{
|
||||
$model = new CategoryModel();
|
||||
$model->update(self::$categoryId, ['name' => 'Updated Work']);
|
||||
|
||||
$found = $model->find(self::$categoryId);
|
||||
$this->assertSame('Updated Work', $found['name']);
|
||||
}
|
||||
|
||||
public function testCategoryModelDelete(): void
|
||||
{
|
||||
$model = new CategoryModel();
|
||||
$model->delete(self::$categoryId);
|
||||
|
||||
$found = $model->find(self::$categoryId);
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PROJECT MODEL
|
||||
// ========================================================================
|
||||
|
||||
public function testProjectModelInsertAndFind(): void
|
||||
{
|
||||
$model = new ProjectModel();
|
||||
|
||||
$data = [
|
||||
'id' => $this->uuid(),
|
||||
'user_id' => self::$userId,
|
||||
'name' => 'Test Project',
|
||||
'color' => '#8B5CF6',
|
||||
];
|
||||
|
||||
$this->assertNotFalse($model->insert($data));
|
||||
|
||||
self::$projectId = $data['id'];
|
||||
|
||||
$found = $model->find($data['id']);
|
||||
$this->assertNotNull($found);
|
||||
$this->assertSame('Test Project', $found['name']);
|
||||
}
|
||||
|
||||
public function testProjectModelUpdate(): void
|
||||
{
|
||||
$model = new ProjectModel();
|
||||
$model->update(self::$projectId, [
|
||||
'description' => 'A test project description',
|
||||
]);
|
||||
|
||||
$found = $model->find(self::$projectId);
|
||||
$this->assertSame('A test project description', $found['description']);
|
||||
}
|
||||
|
||||
public function testProjectModelDelete(): void
|
||||
{
|
||||
$model = new ProjectModel();
|
||||
$model->delete(self::$projectId);
|
||||
|
||||
$found = $model->find(self::$projectId);
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// HELPERS
|
||||
// ========================================================================
|
||||
|
||||
private function uuid(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
}
|
||||
19
tests/bootstrap.php
Normal file
19
tests/bootstrap.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Test Bootstrap
|
||||
*
|
||||
* Sets up the testing environment before the framework boots.
|
||||
* Disables the debug toolbar that would wrap JSON API responses in HTML.
|
||||
* The framework's own Test/bootstrap.php handles ENVIRONMENT constant.
|
||||
*/
|
||||
|
||||
// Disable debug toolbar before the framework defines CI_DEBUG
|
||||
defined('CI_DEBUG') || define('CI_DEBUG', false);
|
||||
|
||||
// Ensure CI_ENVIRONMENT env var is set (framework checks this)
|
||||
putenv('CI_ENVIRONMENT=testing');
|
||||
$_SERVER['CI_ENVIRONMENT'] = 'testing';
|
||||
|
||||
// Load the framework's test bootstrap (defines ENVIRONMENT constant)
|
||||
require __DIR__ . '/../vendor/codeigniter4/framework/system/Test/bootstrap.php';
|
||||
0
writable/.htaccess
Normal file → Executable file
0
writable/.htaccess
Normal file → Executable file
0
writable/cache/index.html
vendored
Normal file → Executable file
0
writable/cache/index.html
vendored
Normal file → Executable file
0
writable/debugbar/index.html
Normal file → Executable file
0
writable/debugbar/index.html
Normal file → Executable file
0
writable/index.html
Normal file → Executable file
0
writable/index.html
Normal file → Executable file
0
writable/logs/index.html
Normal file → Executable file
0
writable/logs/index.html
Normal file → Executable file
0
writable/session/index.html
Normal file → Executable file
0
writable/session/index.html
Normal file → Executable file
0
writable/uploads/index.html
Normal file → Executable file
0
writable/uploads/index.html
Normal file → Executable file
Reference in New Issue
Block a user