diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c2dda54 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 Bitshost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 7d2bb52..424273c 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ OpenAPI (Swagger) docs, and zero code generation. --- -## πŸš€ Features +## πŸš€ ## πŸš€ Features - Auto-discovers tables and columns - Full CRUD endpoints for any table - Configurable authentication (API Key, Basic Auth, JWT, or none) +- Advanced query features: filtering, sorting, pagination +- RBAC: per-table role-based access control +- Admin panel (minimal) - OpenAPI (Swagger) JSON endpoint for instant docs - Clean PSR-4 codebase - PHPUnit tests and extensible architecture @@ -110,6 +113,94 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users" --- + +### πŸ”„ Advanced Query Features (Filtering, Sorting, Pagination) + +The `list` action endpoint now supports advanced query parameters: + +| Parameter | Type | Description | +|--------------|---------|---------------------------------------------------------------------------------------------------| +| `filter` | string | Filter rows by column values. Format: `filter=col1:value1,col2:value2`. Use `%` for wildcards. | +| `sort` | string | Sort by columns. Comma-separated. Use `-` prefix for DESC. Example: `sort=-created_at,name` | +| `page` | int | Page number (1-based). Default: `1` | +| `page_size` | int | Number of rows per page (max 100). Default: `20` | + +**Examples:** + +- `GET /index.php?action=list&table=users&filter=name:Alice` +- `GET /index.php?action=list&table=users&sort=-created_at,name` +- `GET /index.php?action=list&table=users&page=2&page_size=10` +- `GET /index.php?action=list&table=users&filter=email:%gmail.com&sort=name&page=1&page_size=5` + +**Response:** +```json +{ + "data": [ ... array of rows ... ], + "meta": { + "total": 47, + "page": 2, + "page_size": 10, + "pages": 5 + } +} +``` + +--- + +### πŸ“ OpenAPI Path Example + +For `/index.php?action=list&table={table}`: + +```yaml +get: + summary: List rows in {table} with optional filtering, sorting, and pagination + parameters: + - name: table + in: query + required: true + schema: { type: string } + - name: filter + in: query + required: false + schema: { type: string } + description: | + Filter rows by column values. Example: filter=name:Alice,email:%gmail.com + - name: sort + in: query + required: false + schema: { type: string } + description: | + Sort by columns. Example: sort=-created_at,name + - name: page + in: query + required: false + schema: { type: integer, default: 1 } + description: Page number (1-based) + - name: page_size + in: query + required: false + schema: { type: integer, default: 20, maximum: 100 } + description: Number of rows per page (max 100) + responses: + '200': + description: List of rows with pagination meta + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { type: object } + meta: + type: object + properties: + total: { type: integer } + page: { type: integer } + page_size: { type: integer } + pages: { type: integer } +``` + ## πŸ›‘οΈ Security Notes - **Enable authentication for any public deployment!** @@ -128,10 +219,11 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users" ## πŸ—ΊοΈ Roadmap -- RESTful route aliases (`/users/1`) -- OAuth2 provider integration -- More DB support (Postgres, SQLite) -- Pagination, filtering, relations +- Relations / Linked Data (auto-join, populate, or expand related records) +- API Versioning (when needed) +- OAuth/SSO (if targeting SaaS/public) +- More DB support (Postgres, SQLite, etc.) +- Analytics & promotion endpoints --- diff --git a/config/api.example.php b/config/api.example.php index a4b07eb..a3e13c5 100644 --- a/config/api.example.php +++ b/config/api.example.php @@ -1,13 +1,31 @@ false, // true to require authentication - 'auth_method' => 'apikey', // 'apikey', 'basic', 'jwt', 'oauth' + // ... existing config ... + 'auth_enabled' => true, + 'auth_method' => 'basic', // or 'apikey', 'jwt', etc. 'api_keys' => ['changeme123'], - 'basic_users' => ['admin' => 'secret'], - 'jwt_secret' => 'YourSuperSecretKey', - 'jwt_issuer' => 'yourdomain.com', - 'jwt_audience' => 'yourdomain.com', - 'oauth_providers' => [ - // 'google' => ['client_id' => '', 'client_secret' => '', ...] - ] + 'basic_users' => [ + 'admin' => 'secret', + 'user' => 'userpass' + ], + // RBAC config: map users to roles, and roles to table permissions + 'roles' => [ + 'admin' => [ + // full access + '*' => ['list', 'read', 'create', 'update', 'delete'] + ], + 'readonly' => [ + // read only on all tables + '*' => ['list', 'read'] + ], + 'users_manager' => [ + 'users' => ['list', 'read', 'create', 'update'], + 'orders' => ['list', 'read'] + ] + ], + // Map users to roles + 'user_roles' => [ + 'admin' => 'admin', + 'user' => 'readonly' + ], ]; \ No newline at end of file diff --git a/public/index.php b/public/index.php index 6cbbb5a..b34ae48 100644 --- a/public/index.php +++ b/public/index.php @@ -3,6 +3,9 @@ require_once __DIR__ . '/../vendor/autoload.php'; +// Add this line if admin React is enabled. +// \App\Cors::sendHeaders(); + use App\Database; use App\Router; use App\Authenticator; diff --git a/src/ApiGenerator.php b/src/ApiGenerator.php index 00e7ed5..9d541a9 100644 --- a/src/ApiGenerator.php +++ b/src/ApiGenerator.php @@ -1,4 +1,5 @@ inspector = new SchemaInspector($pdo); } - public function list(string $table): array + /** + * Enhanced list: supports filtering, sorting, pagination. + */ + public function list(string $table, array $opts = []): array { - $stmt = $this->pdo->query("SELECT * FROM `$table`"); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + $columns = $this->inspector->getColumns($table); + $colNames = array_column($columns, 'Field'); + + // --- Filtering --- + $where = []; + $params = []; + if (!empty($opts['filter'])) { + // Example filter: ['name:Alice', 'email:gmail.com'] + $filters = explode(',', $opts['filter']); + foreach ($filters as $f) { + $parts = explode(':', $f, 2); + if (count($parts) === 2 && in_array($parts[0], $colNames, true)) { + $col = $parts[0]; + $val = $parts[1]; + // Use LIKE for partial match, = for exact + if (str_contains($val, '%')) { + $where[] = "`$col` LIKE :$col"; + $params[$col] = $val; + } else { + $where[] = "`$col` = :$col"; + $params[$col] = $val; + } + } + } + } + + // --- Sorting --- + $orderBy = ''; + if (!empty($opts['sort'])) { + $orders = []; + $sorts = explode(',', $opts['sort']); + foreach ($sorts as $sort) { + $direction = 'ASC'; + $col = $sort; + if (str_starts_with($sort, '-')) { + $direction = 'DESC'; + $col = substr($sort, 1); + } + if (in_array($col, $colNames, true)) { + $orders[] = "`$col` $direction"; + } + } + if ($orders) { + $orderBy = 'ORDER BY ' . implode(', ', $orders); + } + } + + // --- Pagination --- + $page = max(1, (int)($opts['page'] ?? 1)); + $pageSize = max(1, min(100, (int)($opts['page_size'] ?? 20))); // max 100 rows per page + $offset = ($page - 1) * $pageSize; + $limit = "LIMIT $pageSize OFFSET $offset"; + + $sql = "SELECT * FROM `$table`"; + if ($where) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + if ($orderBy) { + $sql .= ' ' . $orderBy; + } + $sql .= " $limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Optionally: include pagination meta info + $countStmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table`" . ($where ? ' WHERE ' . implode(' AND ', $where) : '')); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + return [ + 'data' => $rows, + 'meta' => [ + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, + 'pages' => (int)ceil($total / $pageSize) + ] + ]; } public function read(string $table, $id): ?array @@ -52,6 +134,10 @@ public function update(string $table, $id, array $data): array foreach ($data as $col => $val) { $sets[] = "`$col` = :$col"; } + // Handle no fields to update + if (empty($sets)) { + return ["error" => "No fields to update. Send at least one column."]; + } $sql = sprintf( "UPDATE `%s` SET %s WHERE `$pk` = :id", $table, @@ -60,13 +146,32 @@ public function update(string $table, $id, array $data): array $stmt = $this->pdo->prepare($sql); $data['id'] = $id; $stmt->execute($data); - return $this->read($table, $id); + // Check if any row was actually updated + if ($stmt->rowCount() === 0) { + // Check if the row exists at all + $existing = $this->read($table, $id); + if ($existing === null) { + return ["error" => "Item with id $id not found in $table."]; + } else { + // The row exists but there was no change (e.g., same data) + return $existing; + } + } + $updated = $this->read($table, $id); + if ($updated === null) { + return ["error" => "Unexpected error: item not found after update."]; + } + return $updated; } - public function delete(string $table, $id): bool + public function delete(string $table, $id): array { $pk = $this->inspector->getPrimaryKey($table); $stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `$pk` = :id"); - return $stmt->execute(['id' => $id]); + $stmt->execute(['id' => $id]); + if ($stmt->rowCount() === 0) { + return ['error' => "Item with id $id not found in $table."]; + } + return ['success' => true]; } -} \ No newline at end of file +} diff --git a/src/Authenticator.php b/src/Authenticator.php index ae45d5d..2241290 100644 --- a/src/Authenticator.php +++ b/src/Authenticator.php @@ -1,4 +1,5 @@ 'Unauthorized']); exit; } -} \ No newline at end of file + + // ... existing code ... + + public function getCurrentUser(): ?string + { + // Basic Auth + if ($this->config['auth_method'] === 'basic' && isset($_SERVER['PHP_AUTH_USER'])) { + return $_SERVER['PHP_AUTH_USER']; + } + // JWT + if ($this->config['auth_method'] === 'jwt') { + $headers = $this->getHeaders(); + $authHeader = $headers['Authorization'] ?? ''; + if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) { + try { + $decoded = \Firebase\JWT\JWT::decode($matches[1], new \Firebase\JWT\Key($this->config['jwt_secret'], 'HS256')); + return $decoded->sub ?? null; + } catch (\Exception $e) { + } + } + } + // For API key or other methods, you can add user tracking as needed + return null; + } + + public function getCurrentUserRole(): ?string + { + $user = $this->getCurrentUser(); + if ($user && !empty($this->config['user_roles'][$user])) { + return $this->config['user_roles'][$user]; + } + // For API key, assign a default role (optional) + return null; + } +} diff --git a/src/Cors.php b/src/Cors.php new file mode 100644 index 0000000..eafcd51 --- /dev/null +++ b/src/Cors.php @@ -0,0 +1,18 @@ +roles = $roles; + $this->userRoles = $userRoles; + } + + public function isAllowed(string $role, string $table, string $action): bool + { + if (!isset($this->roles[$role])) { + return false; + } + $perms = $this->roles[$role]; + // Wildcard table permissions + if (isset($perms['*']) && in_array($action, $perms['*'], true)) { + return true; + } + // Table-specific permissions + if (isset($perms[$table]) && in_array($action, $perms[$table], true)) { + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/Router.php b/src/Router.php index 2a5997b..cb1c181 100644 --- a/src/Router.php +++ b/src/Router.php @@ -1,4 +1,5 @@ inspector = new SchemaInspector($pdo); $this->api = new ApiGenerator($pdo); $this->auth = $auth; + + $this->apiConfig = require __DIR__ . '/../config/api.php'; + $this->authEnabled = $this->apiConfig['auth_enabled'] ?? true; + $this->rbac = new Rbac($this->apiConfig['roles'] ?? [], $this->apiConfig['user_roles'] ?? []); + } + + /** + * Checks if the current user (via Authenticator) is allowed to perform $action on $table. + * If not, sends a 403 response and exits. + * No-op if auth/rbac is disabled. + */ + private function enforceRbac(string $action, ?string $table = null) + { + if (!$this->authEnabled) { + return; // skip RBAC if auth is disabled + } + $role = $this->auth->getCurrentUserRole(); + if (!$role) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden: No role assigned']); + exit; + } + if (!$table) return; + if (!$this->rbac->isAllowed($role, $table, $action)) { + http_response_code(403); + echo json_encode(['error' => "Forbidden: $role cannot $action on $table"]); + exit; + } } public function route(array $query) @@ -37,70 +69,100 @@ public function route(array $query) return; } - // Require authentication for all others - $this->auth->requireAuth(); + // Only require authentication if enabled + if ($this->authEnabled) { + $this->auth->requireAuth(); + } try { switch ($query['action'] ?? '') { case 'tables': + // No per-table RBAC needed echo json_encode($this->inspector->getTables()); break; + case 'columns': if (isset($query['table'])) { + $this->enforceRbac('read', $query['table']); echo json_encode($this->inspector->getColumns($query['table'])); } else { http_response_code(400); echo json_encode(['error' => 'Missing table parameter']); } break; + case 'list': if (isset($query['table'])) { - echo json_encode($this->api->list($query['table'])); + $this->enforceRbac('list', $query['table']); + $opts = [ + 'filter' => $query['filter'] ?? null, + 'sort' => $query['sort'] ?? null, + 'page' => $query['page'] ?? 1, + 'page_size' => $query['page_size'] ?? 20, + ]; + echo json_encode($this->api->list($query['table'], $opts)); } else { http_response_code(400); echo json_encode(['error' => 'Missing table parameter']); } break; + case 'read': if (isset($query['table'], $query['id'])) { + $this->enforceRbac('read', $query['table']); echo json_encode($this->api->read($query['table'], $query['id'])); } else { http_response_code(400); echo json_encode(['error' => 'Missing table or id parameter']); } break; + case 'create': - if (isset($query['table'])) { - $data = $_POST; - echo json_encode($this->api->create($query['table'], $data)); - } else { - http_response_code(400); - echo json_encode(['error' => 'Missing table parameter']); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method Not Allowed']); + break; + } + $this->enforceRbac('create', $query['table']); + $data = $_POST; + if (empty($data) && strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) { + $data = json_decode(file_get_contents('php://input'), true) ?? []; } + echo json_encode($this->api->create($query['table'], $data)); break; + case 'update': - if (isset($query['table'], $query['id'])) { - $data = $_POST; - echo json_encode($this->api->update($query['table'], $query['id'], $data)); - } else { - http_response_code(400); - echo json_encode(['error' => 'Missing table or id parameter']); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method Not Allowed']); + break; + } + $this->enforceRbac('update', $query['table']); + $data = $_POST; + if (empty($data) && strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) { + $data = json_decode(file_get_contents('php://input'), true) ?? []; } + echo json_encode($this->api->update($query['table'], $query['id'], $data)); break; + case 'delete': if (isset($query['table'], $query['id'])) { - echo json_encode(['success' => $this->api->delete($query['table'], $query['id'])]); + $this->enforceRbac('delete', $query['table']); + echo json_encode($this->api->delete($query['table'], $query['id'])); } else { http_response_code(400); echo json_encode(['error' => 'Missing table or id parameter']); } break; + case 'openapi': + // No per-table RBAC needed by default echo json_encode(OpenApiGenerator::generate( $this->inspector->getTables(), $this->inspector )); break; + default: http_response_code(400); echo json_encode(['error' => 'Invalid action']);