From 6cbb6a2e3e26716acaf78cbf30d24e34aed30015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= Date: Wed, 29 Apr 2026 16:01:19 +0200 Subject: [PATCH 01/10] added API and login --- API_DOCUMENTATION.md | 825 ++++++++++++++++++ app/Config/Cors.php | 6 +- app/Config/Filters.php | 1 + app/Config/Routes.php | 71 ++ app/Controllers/Api/BaseController.php | 88 ++ .../Api/V1/ActivityLogController.php | 45 + app/Controllers/Api/V1/AuthController.php | 238 +++++ app/Controllers/Api/V1/CategoryController.php | 133 +++ .../Api/V1/MarketplaceController.php | 42 + app/Controllers/Api/V1/ProjectController.php | 133 +++ .../Api/V1/RecurringTaskController.php | 202 +++++ app/Controllers/Api/V1/TodoController.php | 202 +++++ app/Controllers/Api/V1/UserController.php | 126 +++ .../Api/V1/UserThemeController.php | 120 +++ ...25-01-01-000015_CreateApiAuthKeysTable.php | 81 ++ app/Database/Seeds/SampleDataSeeder.php | 33 + app/Filters/ApiAuthFilter.php | 100 +++ app/Models/ApiAuthKeyModel.php | 164 ++++ public/example_login.html | 122 +++ 19 files changed, 2729 insertions(+), 3 deletions(-) create mode 100644 API_DOCUMENTATION.md create mode 100644 app/Controllers/Api/BaseController.php create mode 100644 app/Controllers/Api/V1/ActivityLogController.php create mode 100644 app/Controllers/Api/V1/AuthController.php create mode 100644 app/Controllers/Api/V1/CategoryController.php create mode 100644 app/Controllers/Api/V1/MarketplaceController.php create mode 100644 app/Controllers/Api/V1/ProjectController.php create mode 100644 app/Controllers/Api/V1/RecurringTaskController.php create mode 100644 app/Controllers/Api/V1/TodoController.php create mode 100644 app/Controllers/Api/V1/UserController.php create mode 100644 app/Controllers/Api/V1/UserThemeController.php create mode 100644 app/Database/Migrations/2025-01-01-000015_CreateApiAuthKeysTable.php create mode 100644 app/Filters/ApiAuthFilter.php create mode 100644 app/Models/ApiAuthKeyModel.php create mode 100644 public/example_login.html diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..8c1eb44 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,825 @@ +# Todo App API Documentation + +## Version: 1.0 + +Base URL: `http://localhost:8080/api/v1` + +## Overview + +This API provides access to the Todo App functionality with versioned endpoints. The API uses API key authentication for protected endpoints, while some endpoints (like the marketplace) are publicly accessible. + +## Authentication + +### API Key Authentication + +Most endpoints require an API key for authentication. The API key should be included in the `X-API-Key` header. + +**Header:** +``` +X-API-Key: todo_your_api_key_here +``` + +### Register a New User + +**Endpoint:** `POST /api/v1/auth/register` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "your_password", + "name": "John Doe", + "avatar_url": "https://example.com/avatar.jpg", + "settings": { + "theme": "dark", + "language": "en" + } +} +``` + +**Response:** +```json +{ + "success": true, + "message": "User registered successfully", + "data": { + "user": { + "id": "user-uuid", + "email": "user@example.com", + "name": "John Doe", + "avatar_url": "https://example.com/avatar.jpg", + "settings": {"theme": "dark"}, + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 00:00:00" + }, + "api_key": "todo_abc123...", + "key_prefix": "todo_abc1" + } +} +``` + +**Important:** Store the API key securely. You won't be able to retrieve it again. + +### Login + +**Endpoint:** `POST /api/v1/auth/login` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "your_password" +} +``` + +**Response (New API Key Created):** +```json +{ + "success": true, + "message": "Login successful", + "data": { + "user": { + "id": "user-uuid", + "email": "user@example.com", + "name": "John Doe" + }, + "api_key": "todo_abc123...", + "key_prefix": "todo_abc1" + } +} +``` + +**Response (Using Existing API Key):** +```json +{ + "success": true, + "message": "Login successful", + "data": { + "user": { + "id": "user-uuid", + "email": "user@example.com", + "name": "John Doe" + }, + "api_key_prefix": "todo_abc1", + "message": "Using existing API key" + } +} +``` + +**Note:** If you already have an active API key, the login will return the key prefix only (not the full key for security). You should store your API key securely after the first login. + +### Creating an API Key (Legacy) + +To create an additional API key, you can use this endpoint: + +**Endpoint:** `POST /api/v1/auth/api-key` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "your_password", + "name": "My App Key", + "scopes": ["read", "write"], + "expires_at": "2026-12-31 23:59:59" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "API key created successfully", + "data": { + "key": "todo_abc123...", + "prefix": "todo_abc1", + "name": "My App Key", + "scopes": ["read", "write"], + "expires_at": "2026-12-31 23:59:59" + } +} +``` + +### Scopes + +API keys can have the following scopes: +- `read` - Read-only access to data +- `write` - Full access to create, update, and delete data + +If no scopes are specified, the key will have full access. + +## Public Endpoints + +These endpoints do not require authentication. + +### Marketplace Themes + +#### Get All Themes +**Endpoint:** `GET /api/v1/marketplace/themes` + +**Response:** +```json +{ + "success": true, + "message": "Marketplace themes retrieved successfully", + "data": [ + { + "id": "theme-id-1", + "name": "Dark Theme", + "description": "A dark theme for the app", + "preview_url": "https://example.com/preview.png", + "price": 0, + "is_free": true, + "created_at": "2025-01-01 00:00:00" + } + ] +} +``` + +#### Get Theme by ID +**Endpoint:** `GET /api/v1/marketplace/themes/{id}` + +**Response:** +```json +{ + "success": true, + "message": "Theme retrieved successfully", + "data": { + "id": "theme-id-1", + "name": "Dark Theme", + "description": "A dark theme for the app", + "preview_url": "https://example.com/preview.png", + "price": 0, + "is_free": true, + "created_at": "2025-01-01 00:00:00" + } +} +``` + +## Protected Endpoints + +These endpoints require an API key in the `X-API-Key` header. + +### User Management + +#### Get User Profile +**Endpoint:** `GET /api/v1/user/profile` + +**Response:** +```json +{ + "success": true, + "message": "Profile retrieved successfully", + "data": { + "id": "user-id", + "email": "user@example.com", + "name": "John Doe", + "avatar_url": null, + "settings": {"theme": "dark"}, + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 00:00:00" + } +} +``` + +#### Update User Profile +**Endpoint:** `PUT /api/v1/user/profile` + +**Request Body:** +```json +{ + "name": "Jane Doe", + "avatar_url": "https://example.com/avatar.jpg", + "settings": {"theme": "light", "language": "en"} +} +``` + +#### List API Keys +**Endpoint:** `GET /api/v1/user/api-keys` + +**Response:** +```json +{ + "success": true, + "message": "API keys retrieved successfully", + "data": [ + { + "id": "key-id", + "key_prefix": "todo_abc1", + "name": "My App Key", + "scopes": ["read", "write"], + "is_active": true, + "last_used_at": "2025-01-01 12:00:00", + "created_at": "2025-01-01 00:00:00" + } + ] +} +``` + +#### Create API Key +**Endpoint:** `POST /api/v1/user/api-keys` + +**Request Body:** +```json +{ + "name": "New App Key", + "scopes": ["read"], + "expires_at": "2026-12-31 23:59:59" +} +``` + +#### Revoke API Key +**Endpoint:** `DELETE /api/v1/user/api-keys/{id}` + +### Categories + +#### Get All Categories +**Endpoint:** `GET /api/v1/categories` + +**Response:** +```json +{ + "success": true, + "message": "Categories retrieved successfully", + "data": [ + { + "id": "cat-id-1", + "user_id": "user-id", + "name": "Work", + "color": "#3B82F6", + "favorite": true, + "created_at": "2025-01-01 00:00:00" + } + ] +} +``` + +#### Create Category +**Endpoint:** `POST /api/v1/categories` + +**Request Body:** +```json +{ + "name": "Personal", + "color": "#10B981", + "favorite": false +} +``` + +#### Get Category +**Endpoint:** `GET /api/v1/categories/{id}` + +#### Update Category +**Endpoint:** `PUT /api/v1/categories/{id}` + +**Request Body:** +```json +{ + "name": "Updated Name", + "color": "#FF5733", + "favorite": true +} +``` + +#### Delete Category +**Endpoint:** `DELETE /api/v1/categories/{id}` + +### Projects + +#### Get All Projects +**Endpoint:** `GET /api/v1/projects` + +**Response:** +```json +{ + "success": true, + "message": "Projects retrieved successfully", + "data": [ + { + "id": "proj-id-1", + "user_id": "user-id", + "name": "Web Redesign", + "description": "Redesign the company website", + "color": "#8B5CF6", + "created_at": "2025-01-01 00:00:00" + } + ] +} +``` + +#### Create Project +**Endpoint:** `POST /api/v1/projects` + +**Request Body:** +```json +{ + "name": "New Project", + "description": "Project description", + "color": "#EC4899" +} +``` + +#### Get Project +**Endpoint:** `GET /api/v1/projects/{id}` + +#### Update Project +**Endpoint:** `PUT /api/v1/projects/{id}` + +**Request Body:** +```json +{ + "name": "Updated Project", + "description": "Updated description", + "color": "#14B8A6" +} +``` + +#### Delete Project +**Endpoint:** `DELETE /api/v1/projects/{id}` + +### Todos + +#### Get All Todos +**Endpoint:** `GET /api/v1/todos` + +**Response:** +```json +{ + "success": true, + "message": "Todos retrieved successfully", + "data": [ + { + "id": "todo-id-1", + "user_id": "user-id", + "title": "Complete task", + "description": "Task description", + "status": "open", + "due_date": "2025-01-15", + "due_time": "10:30:00", + "sync_enabled": true, + "reminder_enabled": false, + "recurring_enabled": false, + "project_id": "proj-id-1", + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 00:00:00", + "categories": [ + { + "id": "cat-id-1", + "name": "Work", + "color": "#3B82F6" + } + ] + } + ] +} +``` + +#### Create Todo +**Endpoint:** `POST /api/v1/todos` + +**Request Body:** +```json +{ + "title": "New Task", + "description": "Task description", + "status": "open", + "due_date": "2025-01-15", + "due_time": "10:30:00", + "sync_enabled": true, + "reminder_enabled": false, + "recurring_enabled": false, + "project_id": "proj-id-1" +} +``` + +**Status options:** `open`, `in_progress`, `completed`, `archived` + +#### Get Todo +**Endpoint:** `GET /api/v1/todos/{id}` + +#### Update Todo +**Endpoint:** `PUT /api/v1/todos/{id}` + +**Request Body:** +```json +{ + "title": "Updated Task", + "status": "in_progress", + "due_date": "2025-01-20" +} +``` + +#### Delete Todo +**Endpoint:** `DELETE /api/v1/todos/{id}` + +#### Add Category to Todo +**Endpoint:** `POST /api/v1/todos/{id}/categories` + +**Request Body:** +```json +{ + "category_id": "cat-id-1" +} +``` + +#### Remove Category from Todo +**Endpoint:** `DELETE /api/v1/todos/{id}/categories/{categoryId}` + +### Recurring Tasks + +#### Get All Recurring Tasks +**Endpoint:** `GET /api/v1/recurring-tasks` + +**Response:** +```json +{ + "success": true, + "message": "Recurring tasks retrieved successfully", + "data": [ + { + "id": "rt-id-1", + "user_id": "user-id", + "title": "Weekly Review", + "description": "Plan next week's tasks", + "schedule": "weekly", + "custom_days": [], + "favorite": true, + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 00:00:00", + "categories": [ + { + "id": "cat-id-1", + "name": "Work", + "color": "#3B82F6" + } + ] + } + ] +} +``` + +#### Create Recurring Task +**Endpoint:** `POST /api/v1/recurring-tasks` + +**Request Body:** +```json +{ + "title": "Daily Standup", + "description": "Team meeting every morning", + "schedule": "daily", + "custom_days": [], + "favorite": true +} +``` + +**Schedule options:** `daily`, `weekly`, `monthly`, `custom` + +For `custom` schedule, provide days in `custom_days` array: `["mon", "wed", "fri"]` + +#### Get Recurring Task +**Endpoint:** `GET /api/v1/recurring-tasks/{id}` + +#### Update Recurring Task +**Endpoint:** `PUT /api/v1/recurring-tasks/{id}` + +**Request Body:** +```json +{ + "title": "Updated Task", + "schedule": "weekly", + "custom_days": ["mon"] +} +``` + +#### Delete Recurring Task +**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}` + +#### Add Category to Recurring Task +**Endpoint:** `POST /api/v1/recurring-tasks/{id}/categories` + +**Request Body:** +```json +{ + "category_id": "cat-id-1" +} +``` + +#### Remove Category from Recurring Task +**Endpoint:** `DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId}` + +### Activity Logs + +#### Get Activity Logs +**Endpoint:** `GET /api/v1/activity-logs?limit=50` + +**Response:** +```json +{ + "success": true, + "message": "Activity logs retrieved successfully", + "data": [ + { + "id": "log-id-1", + "user_id": "user-id", + "action": "todo_created", + "entity_type": "todo", + "entity_id": "todo-id-1", + "details": { + "title": "New Task" + }, + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0...", + "created_at": "2025-01-01 12:00:00" + } + ] +} +``` + +#### Get Activity Log +**Endpoint:** `GET /api/v1/activity-logs/{id}` + +### User Themes + +#### Get User Themes +**Endpoint:** `GET /api/v1/user/themes` + +**Response:** +```json +{ + "success": true, + "message": "User themes retrieved successfully", + "data": [ + { + "id": "ut-id-1", + "user_id": "user-id", + "theme_id": "theme-id-1", + "is_active": true, + "custom_settings": {"primary_color": "#3B82F6"}, + "created_at": "2025-01-01 00:00:00" + } + ] +} +``` + +#### Create User Theme +**Endpoint:** `POST /api/v1/user/themes` + +**Request Body:** +```json +{ + "theme_id": "theme-id-1", + "is_active": true, + "custom_settings": { + "primary_color": "#3B82F6", + "font_size": "medium" + } +} +``` + +#### Update User Theme +**Endpoint:** `PUT /api/v1/user/themes/{id}` + +**Request Body:** +```json +{ + "is_active": false, + "custom_settings": { + "primary_color": "#EC4899" + } +} +``` + +#### Delete User Theme +**Endpoint:** `DELETE /api/v1/user/themes/{id}` + +## Error Responses + +All error responses follow this format: + +```json +{ + "success": false, + "message": "Error message", + "errors": { + "field": "Validation error message" + } +} +``` + +### Common HTTP Status Codes + +- `200 OK` - Request successful +- `201 Created` - Resource created successfully +- `400 Bad Request` - Invalid request data +- `401 Unauthorized` - Missing or invalid API key +- `403 Forbidden` - Insufficient permissions +- `404 Not Found` - Resource not found +- `409 Conflict` - Resource already exists +- `422 Unprocessable Entity` - Validation failed +- `500 Internal Server Error` - Server error + +## Rate Limiting + +Currently, there is no rate limiting implemented. Consider adding rate limiting for production use. + +## CORS + +If you need to enable CORS for frontend applications, configure it in `app/Config/Filters.php`. + +## Example Usage + +### cURL Examples + +**Register a New User:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "newuser@example.com", + "password": "securepassword", + "name": "New User" + }' +``` + +**Login:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "password123" + }' +``` + +**Create API Key (additional key):** +```bash +curl -X POST http://localhost:8080/api/v1/auth/api-key \ + -H "Content-Type: application/json" \ + -d '{ + "email": "demo@example.com", + "password": "password123", + "name": "My App Key" + }' +``` + +**Get Todos (with API key):** +```bash +curl -X GET http://localhost:8080/api/v1/todos \ + -H "X-API-Key: todo_your_api_key_here" +``` + +**Create Todo:** +```bash +curl -X POST http://localhost:8080/api/v1/todos \ + -H "X-API-Key: todo_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "New Task", + "status": "open" + }' +``` + +### JavaScript/Fetch Examples + +**Register a New User:** +```javascript +fetch('http://localhost:8080/api/v1/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'newuser@example.com', + password: 'securepassword', + name: 'New User' + }) +}) +.then(response => response.json()) +.then(data => { + console.log('API Key:', data.data.api_key); + console.log('User:', data.data.user); +}); +``` + +**Login:** +```javascript +fetch('http://localhost:8080/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'user@example.com', + password: 'password123' + }) +}) +.then(response => response.json()) +.then(data => { + if (data.data.api_key) { + console.log('New API Key:', data.data.api_key); + } else { + console.log('Using existing key with prefix:', data.data.api_key_prefix); + } +}); +``` + +**Create API Key (additional key):** +```javascript +fetch('http://localhost:8080/api/v1/auth/api-key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'demo@example.com', + password: 'password123', + name: 'My App Key' + }) +}) +.then(response => response.json()) +.then(data => console.log(data)); +``` + +**Get Todos:** +```javascript +fetch('http://localhost:8080/api/v1/todos', { + headers: { + 'X-API-Key': 'todo_your_api_key_here' + } +}) +.then(response => response.json()) +.then(data => console.log(data)); +``` + +**Create Todo:** +```javascript +fetch('http://localhost:8080/api/v1/todos', { + method: 'POST', + headers: { + 'X-API-Key': 'todo_your_api_key_here', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: 'New Task', + status: 'open' + }) +}) +.then(response => response.json()) +.then(data => console.log(data)); +``` + +## Testing + +To test the API, you can use tools like: +- Postman +- Insomnia +- cURL +- HTTPie + +## Versioning + +The API is versioned using the URL path. The current version is `v1`. Future versions will be numbered incrementally (v2, v3, etc.). + +## Support + +For issues or questions, please refer to the project documentation or contact the development team. diff --git a/app/Config/Cors.php b/app/Config/Cors.php index 2b4edf6..333fbc9 100644 --- a/app/Config/Cors.php +++ b/app/Config/Cors.php @@ -34,7 +34,7 @@ class Cors extends BaseConfig * - ['http://localhost:8080'] * - ['https://www.example.com'] */ - 'allowedOrigins' => [], + 'allowedOrigins' => ['*'], /** * Origin regex patterns for the `Access-Control-Allow-Origin` header. @@ -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'], /** * 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'], /** * Set how many seconds the results of a preflight request can be cached. diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 9c83ae9..c604e0f 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -34,6 +34,7 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'apiauth' => \App\Filters\ApiAuthFilter::class, ]; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index fc4914a..85cd609 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -6,3 +6,74 @@ use CodeIgniter\Router\RouteCollection; * @var RouteCollection $routes */ $routes->get('/', 'Home::index'); + +// ============================================================================ +// API Routes - Version 1.0 +// ============================================================================ + +// 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'); +}); + +// 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'); +}); diff --git a/app/Controllers/Api/BaseController.php b/app/Controllers/Api/BaseController.php new file mode 100644 index 0000000..ebe85da --- /dev/null +++ b/app/Controllers/Api/BaseController.php @@ -0,0 +1,88 @@ +request->user ?? null; + } + + /** + * Get the authenticated user ID + */ + protected function getUserId(): ?string + { + $user = $this->getUser(); + return $user['id'] ?? null; + } + + /** + * Success response + */ + protected function successResponse($data = null, string $message = 'Success', int $statusCode = 200) + { + 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([ + 'success' => true, + 'message' => $message, + 'data' => $data, + ]); + } + + /** + * Error response + */ + protected function errorResponse(string $message, int $statusCode = 400, $errors = null) + { + $response = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors !== null) { + $response['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($response); + } + + /** + * Validate request data + */ + protected function validateRequest(array $rules): bool + { + $validation = \Config\Services::validation(); + + // Handle both old format (string) and new format (array with rules/errors) + 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; + } +} diff --git a/app/Controllers/Api/V1/ActivityLogController.php b/app/Controllers/Api/V1/ActivityLogController.php new file mode 100644 index 0000000..8d08324 --- /dev/null +++ b/app/Controllers/Api/V1/ActivityLogController.php @@ -0,0 +1,45 @@ +activityLogModel = new ActivityLogModel(); + } + + /** + * Get activity logs for the authenticated user + * GET /api/v1/activity-logs + */ + public function index() + { + $userId = $this->getUserId(); + $limit = $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'); + } +} diff --git a/app/Controllers/Api/V1/AuthController.php b/app/Controllers/Api/V1/AuthController.php new file mode 100644 index 0000000..ccb06c8 --- /dev/null +++ b/app/Controllers/Api/V1/AuthController.php @@ -0,0 +1,238 @@ +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'); + } + + /** + * 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) + ); + } +} diff --git a/app/Controllers/Api/V1/CategoryController.php b/app/Controllers/Api/V1/CategoryController.php new file mode 100644 index 0000000..5f7e353 --- /dev/null +++ b/app/Controllers/Api/V1/CategoryController.php @@ -0,0 +1,133 @@ +categoryModel = new CategoryModel(); + } + + /** + * Get all categories for the authenticated user + * GET /api/v1/categories + */ + public function index() + { + $userId = $this->getUserId(); + $categories = $this->categoryModel->where('user_id', $userId)->findAll(); + + return $this->successResponse($categories, 'Categories retrieved successfully'); + } + + /** + * Create a new category + * POST /api/v1/categories + */ + public function create() + { + $userId = $this->getUserId(); + $json = $this->request->getJSON(true); + + $rules = [ + 'name' => 'required|max_length[255]', + 'color' => 'required|max_length[7]', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + $data = [ + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], + 'color' => $json['color'], + 'favorite' => $json['favorite'] ?? false, + ]; + + $this->categoryModel->insert($data); + $category = $this->categoryModel->find($data['id']); + + return $this->successResponse($category, 'Category created successfully', 201); + } + + /** + * Get a specific category + * 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'); + } + + /** + * Update a category + * 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); + $allowedFields = ['name', 'color', 'favorite']; + $updateData = array_intersect_key($json, array_flip($allowedFields)); + + if (empty($updateData)) { + return $this->errorResponse('No valid fields to update'); + } + + $this->categoryModel->update($id, $updateData); + $category = $this->categoryModel->find($id); + + return $this->successResponse($category, 'Category updated successfully'); + } + + /** + * Delete a category + * 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); + + return $this->successResponse(null, 'Category deleted successfully'); + } + + 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) + ); + } +} diff --git a/app/Controllers/Api/V1/MarketplaceController.php b/app/Controllers/Api/V1/MarketplaceController.php new file mode 100644 index 0000000..134ad7b --- /dev/null +++ b/app/Controllers/Api/V1/MarketplaceController.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Controllers/Api/V1/ProjectController.php b/app/Controllers/Api/V1/ProjectController.php new file mode 100644 index 0000000..6bf2e0a --- /dev/null +++ b/app/Controllers/Api/V1/ProjectController.php @@ -0,0 +1,133 @@ +projectModel = new ProjectModel(); + } + + /** + * Get all projects for the authenticated user + * GET /api/v1/projects + */ + public function index() + { + $userId = $this->getUserId(); + $projects = $this->projectModel->where('user_id', $userId)->findAll(); + + return $this->successResponse($projects, 'Projects retrieved successfully'); + } + + /** + * Create a new project + * POST /api/v1/projects + */ + public function create() + { + $userId = $this->getUserId(); + $json = $this->request->getJSON(true); + + $rules = [ + 'name' => 'required|max_length[255]', + 'color' => 'required|max_length[7]', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + $data = [ + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'name' => $json['name'], + 'description' => $json['description'] ?? null, + 'color' => $json['color'], + ]; + + $this->projectModel->insert($data); + $project = $this->projectModel->find($data['id']); + + return $this->successResponse($project, 'Project created successfully', 201); + } + + /** + * Get a specific project + * 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'); + } + + /** + * Update a project + * 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); + + return $this->successResponse($project, 'Project updated successfully'); + } + + /** + * Delete a project + * 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); + + return $this->successResponse(null, 'Project deleted successfully'); + } + + 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) + ); + } +} diff --git a/app/Controllers/Api/V1/RecurringTaskController.php b/app/Controllers/Api/V1/RecurringTaskController.php new file mode 100644 index 0000000..d7fa30b --- /dev/null +++ b/app/Controllers/Api/V1/RecurringTaskController.php @@ -0,0 +1,202 @@ +recurringTaskModel = new RecurringTaskModel(); + $this->recurringTaskCategoryModel = new RecurringTaskCategoryModel(); + } + + /** + * Get all recurring tasks for the authenticated user + * GET /api/v1/recurring-tasks + */ + public function index() + { + $userId = $this->getUserId(); + $tasks = $this->recurringTaskModel->getByUserWithCategories($userId); + + return $this->successResponse($tasks, 'Recurring tasks retrieved successfully'); + } + + /** + * Create a new recurring task + * POST /api/v1/recurring-tasks + */ + public function create() + { + $userId = $this->getUserId(); + $json = $this->request->getJSON(true); + + $rules = [ + 'title' => 'required|max_length[255]', + 'schedule' => 'required|in_list[daily,weekly,monthly,custom]', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + $data = [ + 'id' => $this->generateUuid(), + 'user_id' => $userId, + 'title' => $json['title'], + 'description' => $json['description'] ?? null, + 'schedule' => $json['schedule'], + 'custom_days' => $json['custom_days'] ? json_encode($json['custom_days']) : json_encode([]), + 'favorite' => $json['favorite'] ?? false, + ]; + + $this->recurringTaskModel->insert($data); + $task = $this->recurringTaskModel->getByUserWithCategories($userId, $data['id']); + + return $this->successResponse($task, 'Recurring task created successfully', 201); + } + + /** + * Get a specific recurring task + * GET /api/v1/recurring-tasks/{id} + */ + public function show($id = null) + { + $userId = $this->getUserId(); + $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + + if (!$task) { + return $this->errorResponse('Recurring task not found', 404); + } + + return $this->successResponse($task, 'Recurring task retrieved successfully'); + } + + /** + * Update a recurring task + * 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); + $allowedFields = ['title', 'description', 'schedule', 'custom_days', 'favorite']; + $updateData = array_intersect_key($json, array_flip($allowedFields)); + + if (isset($updateData['custom_days'])) { + $updateData['custom_days'] = json_encode($updateData['custom_days']); + } + + if (empty($updateData)) { + return $this->errorResponse('No valid fields to update'); + } + + $this->recurringTaskModel->update($id, $updateData); + $task = $this->recurringTaskModel->getByUserWithCategories($userId, $id); + + return $this->successResponse($task, 'Recurring task updated successfully'); + } + + /** + * Delete a recurring task + * 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); + + return $this->successResponse(null, 'Recurring task deleted successfully'); + } + + /** + * Add a category to a recurring task + * 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; + } + + // Verify task belongs to user + $task = $this->recurringTaskModel->where('id', $taskId)->where('user_id', $userId)->first(); + if (!$task) { + return $this->errorResponse('Recurring task not found', 404); + } + + // Check if link already exists + $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); + } + + /** + * Remove a category from a recurring task + * DELETE /api/v1/recurring-tasks/{id}/categories/{categoryId} + */ + public function removeCategory($taskId = null, $categoryId = null) + { + $userId = $this->getUserId(); + + // Verify task belongs to user + $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'); + } + + 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) + ); + } +} diff --git a/app/Controllers/Api/V1/TodoController.php b/app/Controllers/Api/V1/TodoController.php new file mode 100644 index 0000000..433e34d --- /dev/null +++ b/app/Controllers/Api/V1/TodoController.php @@ -0,0 +1,202 @@ +todoModel = new TodoModel(); + $this->todoCategoryModel = new TodoCategoryModel(); + } + + /** + * Get all todos for the authenticated user + * GET /api/v1/todos + */ + public function index() + { + $userId = $this->getUserId(); + $todos = $this->todoModel->getByUserWithCategories($userId); + + return $this->successResponse($todos, 'Todos retrieved successfully'); + } + + /** + * Create a new todo + * POST /api/v1/todos + */ + public function create() + { + $userId = $this->getUserId(); + $json = $this->request->getJSON(true); + + $rules = [ + 'title' => 'required|max_length[255]', + 'status' => 'permit_empty|in_list[open,in_progress,completed,archived]', + ]; + + if (!$this->validateRequest($rules)) { + return; + } + + $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' => $json['sync_enabled'] ?? true, + 'reminder_enabled' => $json['reminder_enabled'] ?? false, + 'recurring_enabled' => $json['recurring_enabled'] ?? false, + 'project_id' => $json['project_id'] ?? null, + ]; + + $this->todoModel->insert($data); + $todo = $this->todoModel->getByUserWithCategories($userId, $data['id']); + + return $this->successResponse($todo, 'Todo created successfully', 201); + } + + /** + * Get a specific todo + * GET /api/v1/todos/{id} + */ + public function show($id = null) + { + $userId = $this->getUserId(); + $todo = $this->todoModel->getByUserWithCategories($userId, $id); + + if (!$todo) { + return $this->errorResponse('Todo not found', 404); + } + + return $this->successResponse($todo, 'Todo retrieved successfully'); + } + + /** + * Update a todo + * 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); + $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)) { + return $this->errorResponse('No valid fields to update'); + } + + $this->todoModel->update($id, $updateData); + $todo = $this->todoModel->getByUserWithCategories($userId, $id); + + return $this->successResponse($todo, 'Todo updated successfully'); + } + + /** + * Delete a todo + * 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); + + return $this->successResponse(null, 'Todo deleted successfully'); + } + + /** + * Add a category to a todo + * 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; + } + + // Verify todo belongs to user + $todo = $this->todoModel->where('id', $todoId)->where('user_id', $userId)->first(); + if (!$todo) { + return $this->errorResponse('Todo not found', 404); + } + + // Check if link already exists + $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); + } + + /** + * Remove a category from a todo + * DELETE /api/v1/todos/{id}/categories/{categoryId} + */ + public function removeCategory($todoId = null, $categoryId = null) + { + $userId = $this->getUserId(); + + // Verify todo belongs to user + $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'); + } + + 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) + ); + } +} diff --git a/app/Controllers/Api/V1/UserController.php b/app/Controllers/Api/V1/UserController.php new file mode 100644 index 0000000..68cc870 --- /dev/null +++ b/app/Controllers/Api/V1/UserController.php @@ -0,0 +1,126 @@ +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'); + } +} diff --git a/app/Controllers/Api/V1/UserThemeController.php b/app/Controllers/Api/V1/UserThemeController.php new file mode 100644 index 0000000..110c9e4 --- /dev/null +++ b/app/Controllers/Api/V1/UserThemeController.php @@ -0,0 +1,120 @@ +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'); + } + + 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) + ); + } +} diff --git a/app/Database/Migrations/2025-01-01-000015_CreateApiAuthKeysTable.php b/app/Database/Migrations/2025-01-01-000015_CreateApiAuthKeysTable.php new file mode 100644 index 0000000..6262b17 --- /dev/null +++ b/app/Database/Migrations/2025-01-01-000015_CreateApiAuthKeysTable.php @@ -0,0 +1,81 @@ +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'); + } +} diff --git a/app/Database/Seeds/SampleDataSeeder.php b/app/Database/Seeds/SampleDataSeeder.php index ec9aef2..edc4178 100644 --- a/app/Database/Seeds/SampleDataSeeder.php +++ b/app/Database/Seeds/SampleDataSeeder.php @@ -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"; + } } } diff --git a/app/Filters/ApiAuthFilter.php b/app/Filters/ApiAuthFilter.php new file mode 100644 index 0000000..ab7c7fa --- /dev/null +++ b/app/Filters/ApiAuthFilter.php @@ -0,0 +1,100 @@ +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, + ]); + } +} diff --git a/app/Models/ApiAuthKeyModel.php b/app/Models/ApiAuthKeyModel.php new file mode 100644 index 0000000..1d53c23 --- /dev/null +++ b/app/Models/ApiAuthKeyModel.php @@ -0,0 +1,164 @@ + '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) + ); + } +} diff --git a/public/example_login.html b/public/example_login.html new file mode 100644 index 0000000..5d0a271 --- /dev/null +++ b/public/example_login.html @@ -0,0 +1,122 @@ + + + + Login & Register + + +

Login

+
+
+ + +
+
+
+ + +
+
+ +
+ +
+ +

Register

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+ +

Response

+

+
+    
+
+

From 092bb5332412393e356402860d117887f40c5476 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= 
Date: Wed, 29 Apr 2026 16:59:52 +0200
Subject: [PATCH 02/10] added loging

---
 .../Api/V1/ActivityLogController.php          |  2 +-
 app/Controllers/Api/V1/TodoController.php     | 50 +++++++++++++++++++
 app/Models/ActivityLogModel.php               | 12 ++---
 app/Models/CategoryModel.php                  |  7 ---
 app/Models/LoggableTrait.php                  |  2 +-
 app/Models/ProjectModel.php                   |  7 ---
 app/Models/RecurringTaskModel.php             |  7 ---
 app/Models/TodoModel.php                      |  7 ---
 app/Models/UserModel.php                      |  7 ---
 9 files changed, 55 insertions(+), 46 deletions(-)

diff --git a/app/Controllers/Api/V1/ActivityLogController.php b/app/Controllers/Api/V1/ActivityLogController.php
index 8d08324..09b6cde 100644
--- a/app/Controllers/Api/V1/ActivityLogController.php
+++ b/app/Controllers/Api/V1/ActivityLogController.php
@@ -21,7 +21,7 @@ class ActivityLogController extends BaseController
     public function index()
     {
         $userId = $this->getUserId();
-        $limit = $this->request->getVar('limit') ?? 50;
+        $limit = (int)($this->request->getVar('limit') ?? 50);
         $logs = $this->activityLogModel->getByUser($userId, $limit);
 
         return $this->successResponse($logs, 'Activity logs retrieved successfully');
diff --git a/app/Controllers/Api/V1/TodoController.php b/app/Controllers/Api/V1/TodoController.php
index 433e34d..b492c52 100644
--- a/app/Controllers/Api/V1/TodoController.php
+++ b/app/Controllers/Api/V1/TodoController.php
@@ -62,6 +62,23 @@ class TodoController extends BaseController
         ];
 
         $this->todoModel->insert($data);
+        
+        // Manually log the activity
+        try {
+            $activityLogModel = new \App\Models\ActivityLogModel();
+            $activityLogModel->logActivity([
+                'user_id' => $userId,
+                'action' => 'todo_created',
+                'entity_type' => 'todo',
+                'entity_id' => $data['id'],
+                'details' => json_encode(['action' => 'created', 'title' => $data['title']]),
+                'ip_address' => $this->request->getIPAddress(),
+                'user_agent' => $this->request->getUserAgent()->getAgentString(),
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'Failed to log activity: ' . $e->getMessage());
+        }
+        
         $todo = $this->todoModel->getByUserWithCategories($userId, $data['id']);
 
         return $this->successResponse($todo, 'Todo created successfully', 201);
@@ -105,6 +122,23 @@ class TodoController extends BaseController
         }
 
         $this->todoModel->update($id, $updateData);
+        
+        // Manually log the activity
+        try {
+            $activityLogModel = new \App\Models\ActivityLogModel();
+            $activityLogModel->logActivity([
+                'user_id' => $userId,
+                'action' => 'todo_updated',
+                'entity_type' => 'todo',
+                'entity_id' => $id,
+                'details' => json_encode(['action' => 'updated', 'title' => $todo['title'] ?? 'Unknown']),
+                'ip_address' => $this->request->getIPAddress(),
+                'user_agent' => $this->request->getUserAgent()->getAgentString(),
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'Failed to log activity: ' . $e->getMessage());
+        }
+        
         $todo = $this->todoModel->getByUserWithCategories($userId, $id);
 
         return $this->successResponse($todo, 'Todo updated successfully');
@@ -124,6 +158,22 @@ class TodoController extends BaseController
         }
 
         $this->todoModel->delete($id);
+        
+        // Manually log the activity
+        try {
+            $activityLogModel = new \App\Models\ActivityLogModel();
+            $activityLogModel->logActivity([
+                'user_id' => $userId,
+                'action' => 'todo_deleted',
+                'entity_type' => 'todo',
+                'entity_id' => $id,
+                'details' => json_encode(['action' => 'deleted', 'title' => $todo['title'] ?? 'Unknown']),
+                'ip_address' => $this->request->getIPAddress(),
+                'user_agent' => $this->request->getUserAgent()->getAgentString(),
+            ]);
+        } catch (\Exception $e) {
+            log_message('error', 'Failed to log activity: ' . $e->getMessage());
+        }
 
         return $this->successResponse(null, 'Todo deleted successfully');
     }
diff --git a/app/Models/ActivityLogModel.php b/app/Models/ActivityLogModel.php
index 1b9bc09..003567a 100644
--- a/app/Models/ActivityLogModel.php
+++ b/app/Models/ActivityLogModel.php
@@ -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
diff --git a/app/Models/CategoryModel.php b/app/Models/CategoryModel.php
index a775a0f..d3e7dfb 100644
--- a/app/Models/CategoryModel.php
+++ b/app/Models/CategoryModel.php
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
 
 class CategoryModel extends Model
 {
-    use LoggableTrait;
-
     protected $table = 'categories';
     protected $primaryKey = 'id';
     protected $useAutoIncrement = false;
@@ -30,9 +28,4 @@ class CategoryModel extends Model
         'user_id' => 'required',
         'name' => 'required|max_length[255]',
     ];
-
-    protected function getEntityType(): string
-    {
-        return 'category';
-    }
 }
diff --git a/app/Models/LoggableTrait.php b/app/Models/LoggableTrait.php
index 822ff6b..52bbd0f 100644
--- a/app/Models/LoggableTrait.php
+++ b/app/Models/LoggableTrait.php
@@ -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';
         }
diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php
index c5691f7..da78b0b 100644
--- a/app/Models/ProjectModel.php
+++ b/app/Models/ProjectModel.php
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
 
 class ProjectModel extends Model
 {
-    use LoggableTrait;
-
     protected $table = 'projects';
     protected $primaryKey = 'id';
     protected $useAutoIncrement = false;
@@ -30,9 +28,4 @@ class ProjectModel extends Model
         'user_id' => 'required',
         'name' => 'required|max_length[255]',
     ];
-
-    protected function getEntityType(): string
-    {
-        return 'project';
-    }
 }
diff --git a/app/Models/RecurringTaskModel.php b/app/Models/RecurringTaskModel.php
index 945073d..c209c3d 100644
--- a/app/Models/RecurringTaskModel.php
+++ b/app/Models/RecurringTaskModel.php
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
 
 class RecurringTaskModel extends Model
 {
-    use LoggableTrait;
-
     protected $table = 'recurring_tasks';
     protected $primaryKey = 'id';
     protected $useAutoIncrement = false;
@@ -35,11 +33,6 @@ class RecurringTaskModel extends Model
         'schedule' => 'required|in_list[daily,weekly,monthly,custom]',
     ];
 
-    protected function getEntityType(): string
-    {
-        return 'recurring_task';
-    }
-
     // Get recurring tasks with categories
     public function getWithCategories($taskId = null)
     {
diff --git a/app/Models/TodoModel.php b/app/Models/TodoModel.php
index 3a484f4..bb7c8b8 100644
--- a/app/Models/TodoModel.php
+++ b/app/Models/TodoModel.php
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
 
 class TodoModel extends Model
 {
-    use LoggableTrait;
-
     protected $table = 'todos';
     protected $primaryKey = 'id';
     protected $useAutoIncrement = false;
@@ -39,11 +37,6 @@ class TodoModel extends Model
         'status' => 'permit_empty|in_list[open,in_progress,completed,archived]',
     ];
 
-    protected function getEntityType(): string
-    {
-        return 'todo';
-    }
-
     // Get todos with categories
     public function getWithCategories($todoId = null)
     {
diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php
index ef1145f..177f32e 100644
--- a/app/Models/UserModel.php
+++ b/app/Models/UserModel.php
@@ -6,8 +6,6 @@ use CodeIgniter\Model;
 
 class UserModel extends Model
 {
-    use LoggableTrait;
-
     protected $table = 'users';
     protected $primaryKey = 'id';
     protected $useAutoIncrement = false;
@@ -40,9 +38,4 @@ class UserModel extends Model
             'is_unique' => 'This email is already registered',
         ],
     ];
-
-    protected function getEntityType(): string
-    {
-        return 'user';
-    }
 }

From 7c81586d3fe23897d5e9e0ef49fb41ea9e2c6011 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= 
Date: Wed, 13 May 2026 13:59:31 +0200
Subject: [PATCH 03/10] fix backend CORS: add global CORS filter and catch-all
 OPTIONS route; fix MySQL insert errors: set updatedField to empty string
 instead of null in models without updated_at column

---
 app/Config/Filters.php          | 1 +
 app/Config/Routes.php           | 9 +++++++++
 app/Models/ActivityLogModel.php | 2 +-
 app/Models/AiMessageModel.php   | 2 +-
 app/Models/AiProviderModel.php  | 2 +-
 app/Models/ApiAuthKeyModel.php  | 2 +-
 app/Models/CategoryModel.php    | 2 +-
 app/Models/ProjectModel.php     | 2 +-
 8 files changed, 16 insertions(+), 6 deletions(-)

diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index c604e0f..93508bc 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -73,6 +73,7 @@ class Filters extends BaseFilters
      */
     public array $globals = [
         'before' => [
+            'cors',
             // 'honeypot',
             // 'csrf',
             // 'invalidchars',
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 85cd609..6a9c6e0 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -11,6 +11,15 @@ $routes->get('/', 'Home::index');
 // 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
diff --git a/app/Models/ActivityLogModel.php b/app/Models/ActivityLogModel.php
index 003567a..6848051 100644
--- a/app/Models/ActivityLogModel.php
+++ b/app/Models/ActivityLogModel.php
@@ -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]',
diff --git a/app/Models/AiMessageModel.php b/app/Models/AiMessageModel.php
index 1871407..894c2a9 100644
--- a/app/Models/AiMessageModel.php
+++ b/app/Models/AiMessageModel.php
@@ -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',
diff --git a/app/Models/AiProviderModel.php b/app/Models/AiProviderModel.php
index ce0a587..46d44ed 100644
--- a/app/Models/AiProviderModel.php
+++ b/app/Models/AiProviderModel.php
@@ -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]',
diff --git a/app/Models/ApiAuthKeyModel.php b/app/Models/ApiAuthKeyModel.php
index 1d53c23..9bf7d40 100644
--- a/app/Models/ApiAuthKeyModel.php
+++ b/app/Models/ApiAuthKeyModel.php
@@ -27,7 +27,7 @@ class ApiAuthKeyModel extends Model
 
     protected $useTimestamps = false;
     protected $createdField = 'created_at';
-    protected $updatedField = null;
+    protected $updatedField = '';
 
     protected $validationRules = [
         'user_id' => 'required',
diff --git a/app/Models/CategoryModel.php b/app/Models/CategoryModel.php
index d3e7dfb..84a31be 100644
--- a/app/Models/CategoryModel.php
+++ b/app/Models/CategoryModel.php
@@ -22,7 +22,7 @@ class CategoryModel extends Model
 
     protected $useTimestamps = true;
     protected $createdField = 'created_at';
-    protected $updatedField = null;
+    protected $updatedField = '';
 
     protected $validationRules = [
         'user_id' => 'required',
diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php
index da78b0b..ea69a9d 100644
--- a/app/Models/ProjectModel.php
+++ b/app/Models/ProjectModel.php
@@ -22,7 +22,7 @@ class ProjectModel extends Model
 
     protected $useTimestamps = true;
     protected $createdField = 'created_at';
-    protected $updatedField = null;
+    protected $updatedField = '';
 
     protected $validationRules = [
         'user_id' => 'required',

From fb9ff9d56b88a194f94ba1fa1197672b57523305 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= 
Date: Wed, 13 May 2026 14:08:24 +0200
Subject: [PATCH 04/10] fix todo create/update: link category via
 todo_categories junction table, return single object (not array), include
 category_ids in response

---
 app/Controllers/Api/V1/TodoController.php | 67 +++++++++++++++++++----
 app/Models/TodoModel.php                  | 26 ++++++---
 2 files changed, 73 insertions(+), 20 deletions(-)

diff --git a/app/Controllers/Api/V1/TodoController.php b/app/Controllers/Api/V1/TodoController.php
index b492c52..5df7ed8 100644
--- a/app/Controllers/Api/V1/TodoController.php
+++ b/app/Controllers/Api/V1/TodoController.php
@@ -63,6 +63,11 @@ class TodoController extends BaseController
 
         $this->todoModel->insert($data);
         
+        // Link category if provided
+        if (!empty($json['category_id'])) {
+            $this->linkCategory($data['id'], $json['category_id']);
+        }
+        
         // Manually log the activity
         try {
             $activityLogModel = new \App\Models\ActivityLogModel();
@@ -79,9 +84,9 @@ class TodoController extends BaseController
             log_message('error', 'Failed to log activity: ' . $e->getMessage());
         }
         
-        $todo = $this->todoModel->getByUserWithCategories($userId, $data['id']);
+        $todos = $this->todoModel->getByUserWithCategories($userId, $data['id']);
 
-        return $this->successResponse($todo, 'Todo created successfully', 201);
+        return $this->successResponse($todos[0] ?? null, 'Todo created successfully', 201);
     }
 
     /**
@@ -91,13 +96,13 @@ class TodoController extends BaseController
     public function show($id = null)
     {
         $userId = $this->getUserId();
-        $todo = $this->todoModel->getByUserWithCategories($userId, $id);
+        $todos = $this->todoModel->getByUserWithCategories($userId, $id);
 
-        if (!$todo) {
+        if (empty($todos)) {
             return $this->errorResponse('Todo not found', 404);
         }
 
-        return $this->successResponse($todo, 'Todo retrieved successfully');
+        return $this->successResponse($todos[0], 'Todo retrieved successfully');
     }
 
     /**
@@ -114,14 +119,26 @@ class TodoController extends BaseController
         }
 
         $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'];
+            // Remove all existing category links
+            $this->todoCategoryModel->where('todo_id', $id)->delete();
+            // Link the new category
+            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)) {
-            return $this->errorResponse('No valid fields to update');
+        if (!empty($updateData)) {
+            $this->todoModel->update($id, $updateData);
         }
-
-        $this->todoModel->update($id, $updateData);
         
         // Manually log the activity
         try {
@@ -139,9 +156,9 @@ class TodoController extends BaseController
             log_message('error', 'Failed to log activity: ' . $e->getMessage());
         }
         
-        $todo = $this->todoModel->getByUserWithCategories($userId, $id);
+        $updated = $this->todoModel->getByUserWithCategories($userId, $id);
 
-        return $this->successResponse($todo, 'Todo updated successfully');
+        return $this->successResponse($updated[0] ?? null, 'Todo updated successfully');
     }
 
     /**
@@ -238,6 +255,34 @@ class TodoController extends BaseController
         return $this->successResponse(null, 'Category removed from todo successfully');
     }
 
+    /**
+     * Link a category to a todo
+     */
+    private function linkCategory(string $todoId, string $categoryId): void
+    {
+        $userId = $this->getUserId();
+        
+        // Verify category belongs to user
+        $categoryModel = new \App\Models\CategoryModel();
+        $category = $categoryModel->where('id', $categoryId)->where('user_id', $userId)->first();
+        if (!$category) {
+            return;
+        }
+        
+        // Check if link already exists
+        $existing = $this->todoCategoryModel
+            ->where('todo_id', $todoId)
+            ->where('category_id', $categoryId)
+            ->first();
+            
+        if (!$existing) {
+            $this->todoCategoryModel->insert([
+                'todo_id' => $todoId,
+                'category_id' => $categoryId,
+            ]);
+        }
+    }
+
     private function generateUuid(): string
     {
         return sprintf(
diff --git a/app/Models/TodoModel.php b/app/Models/TodoModel.php
index bb7c8b8..c04012e 100644
--- a/app/Models/TodoModel.php
+++ b/app/Models/TodoModel.php
@@ -52,15 +52,23 @@ class TodoModel extends Model
         return $builder->get()->getResultArray();
     }
 
-    // Get todos by user with categories
-    public function getByUserWithCategories($userId)
+    // Get todos by user with categories (optionally filtered by todo id)
+    public function getByUserWithCategories($userId, $todoId = null)
     {
-        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();
+        $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);
+        }
+
+        return $builder->get()->getResultArray();
     }
 }

From e125ac34d7af9c0289f1a19b9bd773f242cc8fc5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=BCrg=20Hallenbarter?= 
Date: Wed, 13 May 2026 14:25:32 +0200
Subject: [PATCH 05/10] fix category duplicates: validate unique name on create
 and rename, return proper 409 error instead of SQL 500

---
 app/Controllers/Api/V1/CategoryController.php | 24 +++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/app/Controllers/Api/V1/CategoryController.php b/app/Controllers/Api/V1/CategoryController.php
index 5f7e353..9da6885 100644
--- a/app/Controllers/Api/V1/CategoryController.php
+++ b/app/Controllers/Api/V1/CategoryController.php
@@ -44,6 +44,16 @@ class CategoryController extends BaseController
             return;
         }
 
+        // Check for duplicate name 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,
@@ -88,6 +98,20 @@ class CategoryController extends BaseController
         }
 
         $json = $this->request->getJSON(true);
+
+        // Check for duplicate name on rename (excluding current category)
+        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));
 

From daa6ec8b1e8320153cab27c8a2ac77e5035b0a38 Mon Sep 17 00:00:00 2001
From: Cametendo 
Date: Wed, 13 May 2026 16:06:27 +0200
Subject: [PATCH 06/10] Merge feature/marketplace into main

---
 .gitignore                                    |   4 +-
 app/Config/Routes.php                         |   3 +
 app/Controllers/ThemeStore.php                | 127 +++
 .../Seeds/MarketplaceThemesSeeder.php         | 298 ++++++-
 app/Views/theme_store.php                     | 767 ++++++++++++++++++
 env.example                                   |  12 +-
 public/themes/arctic-frost.css                |  16 +
 public/themes/forest-grove.css                |  16 +
 public/themes/midnight-void.css               |  16 +
 public/themes/obsidian-rose.css               |  16 +
 public/themes/ocean-breeze.css                |  16 +
 public/themes/sunset-ember.css                |  16 +
 public/todo-preview                           |   1 +
 writable/.htaccess                            |   0
 writable/cache/index.html                     |   0
 writable/debugbar/index.html                  |   0
 writable/index.html                           |   0
 writable/logs/index.html                      |   0
 writable/session/index.html                   |   0
 writable/uploads/index.html                   |   0
 20 files changed, 1283 insertions(+), 25 deletions(-)
 create mode 100644 app/Controllers/ThemeStore.php
 create mode 100644 app/Views/theme_store.php
 create mode 100644 public/themes/arctic-frost.css
 create mode 100644 public/themes/forest-grove.css
 create mode 100644 public/themes/midnight-void.css
 create mode 100644 public/themes/obsidian-rose.css
 create mode 100644 public/themes/ocean-breeze.css
 create mode 100644 public/themes/sunset-ember.css
 create mode 120000 public/todo-preview
 mode change 100644 => 100755 writable/.htaccess
 mode change 100644 => 100755 writable/cache/index.html
 mode change 100644 => 100755 writable/debugbar/index.html
 mode change 100644 => 100755 writable/index.html
 mode change 100644 => 100755 writable/logs/index.html
 mode change 100644 => 100755 writable/session/index.html
 mode change 100644 => 100755 writable/uploads/index.html

diff --git a/.gitignore b/.gitignore
index 035d487..4dee025 100644
--- a/.gitignore
+++ b/.gitignore
@@ -125,4 +125,6 @@ _modules/*
 /results/
 /phpunit*.xml
 .env
-env
\ No newline at end of file
+env
+.claude/
+.claude/*
\ No newline at end of file
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 6a9c6e0..d0143f2 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -6,6 +6,9 @@ use CodeIgniter\Router\RouteCollection;
  * @var RouteCollection $routes
  */
 $routes->get('/', 'Home::index');
+$routes->get('/themes', 'ThemeStore::index');
+$routes->post('/themes/upload', 'ThemeStore::upload');
+$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
 
 // ============================================================================
 // API Routes - Version 1.0
diff --git a/app/Controllers/ThemeStore.php b/app/Controllers/ThemeStore.php
new file mode 100644
index 0000000..e30ad17
--- /dev/null
+++ b/app/Controllers/ThemeStore.php
@@ -0,0 +1,127 @@
+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']   ?? [];
+        }
+
+        return view('theme_store', [
+            'themes'  => $themes,
+            'flash_success' => session()->getFlashdata('success'),
+            'flash_error'   => session()->getFlashdata('error'),
+        ]);
+    }
+
+    public function upload(): Response
+    {
+        $file        = $this->request->getFile('theme_css');
+        $displayName = trim($this->request->getPost('display_name') ?? '');
+        $description = trim($this->request->getPost('description') ?? '');
+
+        if ($displayName === '') {
+            return redirect()->to('/themes')->with('error', 'Display name is required.');
+        }
+
+        if (! $file || ! $file->isValid() || $file->hasMoved()) {
+            return redirect()->to('/themes')->with('error', 'Please upload a valid CSS file.');
+        }
+
+        if (strtolower($file->getExtension()) !== 'css') {
+            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);
+
+        $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' => []]),
+            'created_at'   => date('Y-m-d H:i:s'),
+            'updated_at'   => date('Y-m-d H:i:s'),
+        ]);
+
+        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('

Theme not found.

'); + } + + $distIndex = '/home/came/Nextcloud/arch-work/Projects/Todo-App/dist/index.html'; + + if (! file_exists($distIndex)) { + return $this->response->setBody( + '' + . '

Todo app dist not found.

' + ); + } + + $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 = ""; + + $todoHtml = str_replace('', $styleTag . "\n", $todoHtml); + + return $this->response + ->setHeader('Content-Type', 'text/html; charset=utf-8') + ->setBody($todoHtml); + } + + 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)); + } +} diff --git a/app/Database/Seeds/MarketplaceThemesSeeder.php b/app/Database/Seeds/MarketplaceThemesSeeder.php index 9d26bc3..5933fdb 100644 --- a/app/Database/Seeds/MarketplaceThemesSeeder.php +++ b/app/Database/Seeds/MarketplaceThemesSeeder.php @@ -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'), ], diff --git a/app/Views/theme_store.php b/app/Views/theme_store.php new file mode 100644 index 0000000..b953bbd --- /dev/null +++ b/app/Views/theme_store.php @@ -0,0 +1,767 @@ + + + + + + Theme Store + + + + +
+

Theme Store

+

Beautiful, ready-to-use themes for your application

+
+ free themes + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ +
+
+ +
+ + +
+ +
+
+
+ + v +
+
by
+

+
+ + + +
+
+ + ⇓ Download + + +
+
+
+ +
+
+ + + + + + + +
+ © Theme Store — All themes are free to use +
+ + + + + diff --git a/env.example b/env.example index f359ec2..b12e39c 100644 --- a/env.example +++ b/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 diff --git a/public/themes/arctic-frost.css b/public/themes/arctic-frost.css new file mode 100644 index 0000000..8ebf313 --- /dev/null +++ b/public/themes/arctic-frost.css @@ -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; } diff --git a/public/themes/forest-grove.css b/public/themes/forest-grove.css new file mode 100644 index 0000000..a953d12 --- /dev/null +++ b/public/themes/forest-grove.css @@ -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; } diff --git a/public/themes/midnight-void.css b/public/themes/midnight-void.css new file mode 100644 index 0000000..c334f3f --- /dev/null +++ b/public/themes/midnight-void.css @@ -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; } diff --git a/public/themes/obsidian-rose.css b/public/themes/obsidian-rose.css new file mode 100644 index 0000000..7bf09da --- /dev/null +++ b/public/themes/obsidian-rose.css @@ -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; } diff --git a/public/themes/ocean-breeze.css b/public/themes/ocean-breeze.css new file mode 100644 index 0000000..c79a9da --- /dev/null +++ b/public/themes/ocean-breeze.css @@ -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; } diff --git a/public/themes/sunset-ember.css b/public/themes/sunset-ember.css new file mode 100644 index 0000000..3a9791e --- /dev/null +++ b/public/themes/sunset-ember.css @@ -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; } diff --git a/public/todo-preview b/public/todo-preview new file mode 120000 index 0000000..ad46dac --- /dev/null +++ b/public/todo-preview @@ -0,0 +1 @@ +/home/came/Nextcloud/arch-work/Projects/Todo-App/dist \ No newline at end of file diff --git a/writable/.htaccess b/writable/.htaccess old mode 100644 new mode 100755 diff --git a/writable/cache/index.html b/writable/cache/index.html old mode 100644 new mode 100755 diff --git a/writable/debugbar/index.html b/writable/debugbar/index.html old mode 100644 new mode 100755 diff --git a/writable/index.html b/writable/index.html old mode 100644 new mode 100755 diff --git a/writable/logs/index.html b/writable/logs/index.html old mode 100644 new mode 100755 diff --git a/writable/session/index.html b/writable/session/index.html old mode 100644 new mode 100755 diff --git a/writable/uploads/index.html b/writable/uploads/index.html old mode 100644 new mode 100755 From bb09f3d0249f410ff0a4994994f05f81211c89f2 Mon Sep 17 00:00:00 2001 From: Cametendo Date: Wed, 6 May 2026 14:17:25 +0200 Subject: [PATCH 07/10] Add marketplace --- app/Config/Routes.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d0143f2..e0043d4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -89,3 +89,9 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [ $routes->put('user/themes/(:segment)', 'UserThemeController::update/$1'); $routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1'); }); +<<<<<<< Updated upstream +======= +$routes->get('/themes', 'ThemeStore::index'); +$routes->post('/themes/upload', 'ThemeStore::upload'); +$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1'); +>>>>>>> Stashed changes From f27498dc2602b008f6a42e5a80b3ca9c2f1aebb9 Mon Sep 17 00:00:00 2001 From: Cametendo Date: Wed, 6 May 2026 14:24:53 +0200 Subject: [PATCH 08/10] Fix merge conflict --- app/Config/Routes.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index e0043d4..4bd96a7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -89,9 +89,6 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [ $routes->put('user/themes/(:segment)', 'UserThemeController::update/$1'); $routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1'); }); -<<<<<<< Updated upstream -======= $routes->get('/themes', 'ThemeStore::index'); $routes->post('/themes/upload', 'ThemeStore::upload'); $routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1'); ->>>>>>> Stashed changes From caf81ea4e21fd73a39cf06aa6d08180384d80ea4 Mon Sep 17 00:00:00 2001 From: Cametendo Date: Wed, 13 May 2026 15:29:47 +0200 Subject: [PATCH 09/10] Working marketplace --- app/Config/App.php | 2 +- app/Config/Cors.php | 2 +- app/Config/Routes.php | 14 ++ app/Controllers/ThemeStore.php | 151 +++++++++++++++++- app/Views/theme_store.php | 140 +++++++++++++--- public/.htaccess | 1 - public/themes/2341342134-1441f7.css | 38 +++++ public/themes/extract-test-theme-5fae6e.css | 37 +++++ public/themes/manual-game-update-2-e1a77a.css | 38 +++++ public/themes/manual-game-update-7cc79d.css | 38 +++++ public/themes/red-extract-theme-a3aabe.css | 37 +++++ public/themes/test-theme-103fb1.css | 37 +++++ public/themes/test-theme-6fcabb.css | 37 +++++ .../themestore-theme-by-came-0da6fd.css | 37 +++++ 14 files changed, 577 insertions(+), 32 deletions(-) create mode 100644 public/themes/2341342134-1441f7.css create mode 100644 public/themes/extract-test-theme-5fae6e.css create mode 100644 public/themes/manual-game-update-2-e1a77a.css create mode 100644 public/themes/manual-game-update-7cc79d.css create mode 100644 public/themes/red-extract-theme-a3aabe.css create mode 100644 public/themes/test-theme-103fb1.css create mode 100644 public/themes/test-theme-6fcabb.css create mode 100644 public/themes/themestore-theme-by-came-0da6fd.css diff --git a/app/Config/App.php b/app/Config/App.php index b761da7..5c9560f 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -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. diff --git a/app/Config/Cors.php b/app/Config/Cors.php index 333fbc9..ceae347 100644 --- a/app/Config/Cors.php +++ b/app/Config/Cors.php @@ -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. diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4bd96a7..c01ff7b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -90,5 +90,19 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [ $routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1'); }); $routes->get('/themes', 'ThemeStore::index'); +$routes->options('/themes', static function () { + header('Access-Control-Allow-Origin: http://localhost:5173'); + header('Access-Control-Allow-Methods: GET, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch'); + header('Access-Control-Allow-Credentials: true'); + return response()->setStatusCode(204); +}); $routes->post('/themes/upload', 'ThemeStore::upload'); +$routes->options('/themes/upload', static function () { + header('Access-Control-Allow-Origin: http://localhost:5173'); + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch'); + header('Access-Control-Allow-Credentials: true'); + return response()->setStatusCode(204); +}); $routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1'); diff --git a/app/Controllers/ThemeStore.php b/app/Controllers/ThemeStore.php index e30ad17..d3a26db 100644 --- a/app/Controllers/ThemeStore.php +++ b/app/Controllers/ThemeStore.php @@ -3,11 +3,12 @@ namespace App\Controllers; use App\Models\MarketplaceThemeModel; +use App\Models\UserThemeModel; use CodeIgniter\HTTP\Response; class ThemeStore extends BaseController { - public function index(): string + public function index() { $model = new MarketplaceThemeModel(); $themes = $model->where('is_published', 1)->findAll(); @@ -17,6 +18,15 @@ class ThemeStore extends BaseController $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')) { + header('Access-Control-Allow-Origin: http://localhost:5173'); + header('Access-Control-Allow-Credentials: true'); + return $this->response->setJSON($themes); } return view('theme_store', [ @@ -28,20 +38,32 @@ class ThemeStore extends BaseController public function upload(): Response { + header('Access-Control-Allow-Origin: http://localhost:5173'); + header('Access-Control-Allow-Credentials: true'); + $file = $this->request->getFile('theme_css'); $displayName = trim($this->request->getPost('display_name') ?? ''); $description = trim($this->request->getPost('description') ?? ''); if ($displayName === '') { - return redirect()->to('/themes')->with('error', 'Display name is required.'); + 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()) { - return redirect()->to('/themes')->with('error', 'Please upload a valid CSS file.'); + 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') { - return redirect()->to('/themes')->with('error', 'Only .css files are allowed.'); + 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)); @@ -50,6 +72,23 @@ class ThemeStore extends BaseController $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(), @@ -62,12 +101,22 @@ class ThemeStore extends BaseController 'download_url' => '/themes/' . $filename, 'price' => 0, 'is_published' => true, - 'metadata' => json_encode(['tags' => ['custom', 'community'], 'colors' => []]), + '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'), ]); - return redirect()->to('/themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!'); + 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 @@ -117,6 +166,96 @@ class ThemeStore extends BaseController ->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); diff --git a/app/Views/theme_store.php b/app/Views/theme_store.php index b953bbd..faf4eea 100644 --- a/app/Views/theme_store.php +++ b/app/Views/theme_store.php @@ -164,7 +164,7 @@ } .btn:hover { opacity: 0.88; transform: translateY(-1px); } .btn-download { - background: linear-gradient(135deg, #7c3aed, #6d28d9); + background: linear-gradient(135deg, #22c55e, #16a34a); color: #fff; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 6px; } @@ -292,7 +292,7 @@ .modal-actions { display: flex; gap: 10px; } .btn-download-lg { flex: 1; padding: 13px; font-size: 0.95rem; border-radius: 10px; - background: linear-gradient(135deg, #7c3aed, #6d28d9); + 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; @@ -510,21 +510,31 @@
- - ⇓ Download - +
@@ -570,9 +580,9 @@ @@ -608,7 +618,7 @@
-
+
@@ -643,12 +653,93 @@