diff --git a/.env.example b/.env.example index 73c206e..121e60f 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,52 @@ DB_PORT=5432 # API URL (as seen by the browser) API_URL=http://localhost:8080/api +# API origin used in the nginx Content-Security-Policy connect-src (no /api suffix). +# Must match the host:port of API_URL. Override when deploying to a real server. +NGINX_API_URL=http://localhost:8080 # Google Gemini API Key (free at https://aistudio.google.com/apikey) # Required for book cover scanning feature. Falls back to Tesseract OCR if empty. GEMINI_API_KEY= + +# ───────────────────────────────────────────────────────────── +# SMTP — Email (password reset, access requests, credentials delivery) +# ───────────────────────────────────────────────────────────── + +# SMTP driver — only "smtp" is supported +MAIL_DRIVER=smtp + +# SMTP server hostname +# Examples: +# Gmail: smtp.gmail.com +# Outlook: smtp-mail.outlook.com +# OVH: ssl0.ovh.net +# Self-hosted: mail.yourdomain.com +MAIL_HOST=smtp.gmail.com + +# SMTP port +# 587 → STARTTLS (recommended) +# 465 → SSL/TLS (legacy) +# 25 → plain (not recommended) +MAIL_PORT=587 + +# Encryption: tls (STARTTLS on port 587) | ssl (SSL on port 465) | (empty for plain) +MAIL_ENCRYPTION=tls + +# SMTP authentication credentials +# For Gmail: use an App Password (not your account password) +# Generate one at https://myaccount.google.com/apppasswords +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=CHANGE_ME_SMTP_APP_PASSWORD + +# Sender identity shown in email From: header +MAIL_FROM_ADDRESS=your-email@gmail.com +MAIL_FROM_NAME=Bookoholik + +# Base URL of the frontend (used in password-reset and invitation links) +# Must be reachable by the recipient of the email +# Examples: +# http://localhost:3000 +# http://192.168.1.12:3000 +# https://library.yourdomain.com +APP_URL=http://localhost:3000 diff --git a/.env.ssl.example b/.env.ssl.example new file mode 100644 index 0000000..e148555 --- /dev/null +++ b/.env.ssl.example @@ -0,0 +1,55 @@ +# ───────────────────────────────────────────────────────────────────────────── +# .env.ssl.example — SSL / HTTPS configuration for Bookoholik +# +# Copy this file to .env.ssl and fill in your values: +# cp .env.ssl.example .env.ssl +# +# Then start with: +# docker compose -f docker-compose.yml -f docker-compose.ssl.yml \ +# --env-file .env --env-file .env.ssl \ +# up -d --build +# ───────────────────────────────────────────────────────────────────────────── + +# ── SSL mode ────────────────────────────────────────────────────────────────── +# +# Choose ONE of the following: +# +# nginx-selfsigned Self-signed cert or mkcert cert (home LAN, no domain needed) +# nginx-letsencrypt Bring-your-own Let's Encrypt cert (certbot) +# caddy Caddy with automatic Let's Encrypt (public domain required) +# +SSL_MODE=nginx-selfsigned + +# ── Your server address ─────────────────────────────────────────────────────── +# +# For nginx-selfsigned: your LAN IP or hostname (e.g. 192.168.1.12, bookoholik.home) +# For caddy: your public domain (e.g. bookoholik.yourdomain.com) +# +SSL_DOMAIN=192.168.1.12 + +# ── Certificate paths (nginx-selfsigned / nginx-letsencrypt only) ───────────── +# +# For nginx-selfsigned (after running ./docker/ssl-gen.sh): +# Leave defaults — certs are placed in ./certs/ by ssl-gen.sh +# +# For nginx-letsencrypt (certbot certificates): +# SSL_CERT_FILE=/etc/letsencrypt/live/bookoholik.yourdomain.com/fullchain.pem +# SSL_KEY_FILE=/etc/letsencrypt/live/bookoholik.yourdomain.com/privkey.pem +# +SSL_CERT_FILE=./certs/server.crt +SSL_KEY_FILE=./certs/server.key + +# ───────────────────────────────────────────────────────────────────────────── +# IMPORTANT: also update your base .env when switching to HTTPS +# ───────────────────────────────────────────────────────────────────────────── +# +# Change these in your .env file (not here): +# +# CORS_ORIGIN=https://192.168.1.12 # or https://bookoholik.yourdomain.com +# API_URL=https://192.168.1.12/api # no port needed — proxy handles routing +# APP_URL=https://192.168.1.12 # used in password-reset email links +# NGINX_API_URL=https://192.168.1.12 # used in the frontend CSP connect-src +# +# Rebuild the frontend after changing API_URL (it's baked in at build time): +# docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d --build frontend +# diff --git a/.gitignore b/.gitignore index f35c57b..8079cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ node_modules/ vendor/ .env +.env.ssl dist/ storage/backups/*.sql.gz *.log .DS_Store + +# TLS certificates — never commit private keys +certs/ diff --git a/backend/app/Controllers/AccessRequestController.php b/backend/app/Controllers/AccessRequestController.php new file mode 100644 index 0000000..aa62975 --- /dev/null +++ b/backend/app/Controllers/AccessRequestController.php @@ -0,0 +1,214 @@ +getRequestBody(); + $email = trim($data['email'] ?? ''); + $message = trim($data['message'] ?? ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->json(['error' => 'A valid email address is required.'], 422); + return; + } + + // Rate limiting: 3 requests per hour per IP to prevent admin inbox flooding + $rateLimiter = new \App\Middleware\RateLimiter(); + $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + if (!$rateLimiter->attempt('access-request:' . $clientIp, 3, 3600)) { + $this->json(['error' => 'Too many requests. Please try again later.'], 429); + return; + } + + $db = Database::getConnection(); + + // Reject if already a user + $stmt = $db->prepare('SELECT id FROM users WHERE email = :email'); + $stmt->execute(['email' => $email]); + if ($stmt->fetch()) { + // Don't reveal account existence — respond generically + $this->json(['message' => 'Your request has been submitted.']); + return; + } + + // Reject duplicate pending request + $stmt = $db->prepare("SELECT id FROM access_requests WHERE email = :email AND status = 'pending'"); + $stmt->execute(['email' => $email]); + if ($stmt->fetch()) { + $this->json(['error' => 'A request from this email address is already pending review. You will be contacted once it is processed.'], 409); + return; + } + + // Save request + $stmt = $db->prepare(" + INSERT INTO access_requests (email, message) + VALUES (:email, :message) + "); + $stmt->execute([ + 'email' => $this->sanitize($email), + 'message' => $message ? $this->sanitize($message) : null, + ]); + + // Notify admin by email (best-effort) + try { + $adminStmt = $db->query("SELECT email FROM users WHERE role = 'admin' AND is_active = TRUE LIMIT 1"); + $admin = $adminStmt->fetch(); + if ($admin) { + MailService::sendAccessRequestNotification($admin['email'], $email, $message ?: null); + } + } catch (\Exception $e) { + error_log('Access request notification error: ' . $e->getMessage()); + } + + $this->json(['message' => 'Your request has been submitted. An admin will review it shortly.'], 201); + } + + // ========================================================= + // GET /api/users/access-requests (admin) + // ========================================================= + public function index(array $params): void + { + $db = Database::getConnection(); + $q = $this->getQueryParams(); + $status = in_array($q['status'] ?? 'pending', ['pending', 'approved', 'rejected', 'all'], true) + ? ($q['status'] ?? 'pending') + : 'pending'; + + if ($status === 'all') { + $stmt = $db->query("SELECT * FROM access_requests ORDER BY created_at DESC LIMIT 100"); + } else { + $stmt = $db->prepare("SELECT * FROM access_requests WHERE status = :s ORDER BY created_at DESC LIMIT 100"); + $stmt->execute(['s' => $status]); + } + + $this->json(['data' => $stmt->fetchAll()]); + } + + // ========================================================= + // POST /api/users/access-requests/{id}/approve (admin) + // Body: { "full_name": "...", "username": "...", "role": "user" } + // Creates a user account and sends credentials by email. + // ========================================================= + public function approve(array $params): void + { + $data = $this->getRequestBody(); + $db = Database::getConnection(); + + $stmt = $db->prepare("SELECT * FROM access_requests WHERE id = :id"); + $stmt->execute(['id' => $params['id']]); + $request = $stmt->fetch(); + + if (!$request) { + $this->json(['error' => 'Access request not found.'], 404); + return; + } + if ($request['status'] !== 'pending') { + $this->json(['error' => 'This request has already been processed.'], 409); + return; + } + + $fullName = !empty($data['full_name']) ? $this->sanitize($data['full_name']) : ''; + $username = !empty($data['username']) ? $this->sanitize($data['username']) : ''; + $role = in_array($data['role'] ?? 'user', ['admin', 'user', 'viewer'], true) + ? ($data['role'] ?? 'user') : 'user'; + + if (!$fullName || !$username) { + $this->json(['error' => 'full_name and username are required.'], 422); + return; + } + + // Check username uniqueness + $stmt = $db->prepare('SELECT id FROM users WHERE username = :u OR email = :e'); + $stmt->execute(['u' => $username, 'e' => $request['email']]); + if ($stmt->fetch()) { + $this->json(['error' => 'Username or email already exists.'], 409); + return; + } + + // Generate temporary password + $tempPassword = $this->generateTempPassword(); + $hash = password_hash($tempPassword, PASSWORD_ARGON2ID); + + $stmt = $db->prepare(" + INSERT INTO users (username, email, password_hash, full_name, role, must_change_password) + VALUES (:username, :email, :hash, :full_name, :role, TRUE) + RETURNING id, username, email, full_name, role + "); + $stmt->execute([ + 'username' => $username, + 'email' => $request['email'], + 'hash' => $hash, + 'full_name' => $fullName, + 'role' => $role, + ]); + $newUser = $stmt->fetch(); + + // Mark request as approved + $stmt = $db->prepare("UPDATE access_requests SET status = 'approved', updated_at = NOW() WHERE id = :id"); + $stmt->execute(['id' => $params['id']]); + + // Send credentials email + try { + MailService::sendCredentials($request['email'], $fullName, $username, $tempPassword); + } catch (\Exception $e) { + error_log('Credentials email error: ' . $e->getMessage()); + } + + $this->json(['message' => 'Account created and credentials sent.', 'data' => $newUser], 201); + } + + // ========================================================= + // DELETE /api/users/access-requests/{id} (admin — reject) + // ========================================================= + public function reject(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->prepare("UPDATE access_requests SET status = 'rejected', updated_at = NOW() WHERE id = :id AND status = 'pending'"); + $stmt->execute(['id' => $params['id']]); + + if ($stmt->rowCount() === 0) { + $this->json(['error' => 'Request not found or already processed.'], 404); + return; + } + + $this->json(['message' => 'Request rejected.']); + } + + // ── Helpers ──────────────────────────────────────────────── + + private function generateTempPassword(): string + { + // Cryptographically secure random password: 3 upper + 5 lower + 4 digits (12 chars) + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + $lower = 'abcdefghjkmnpqrstuvwxyz'; + $digits = '23456789'; + + $password = ''; + // Pick characters using random_int (CSPRNG-backed) instead of str_shuffle + for ($i = 0; $i < 3; $i++) { $password .= $upper[random_int(0, strlen($upper) - 1)]; } + for ($i = 0; $i < 5; $i++) { $password .= $lower[random_int(0, strlen($lower) - 1)]; } + for ($i = 0; $i < 4; $i++) { $password .= $digits[random_int(0, strlen($digits) - 1)]; } + + // Fisher-Yates shuffle using random_int + $chars = str_split($password); + for ($i = count($chars) - 1; $i > 0; $i--) { + $j = random_int(0, $i); + [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]]; + } + return implode('', $chars); + } +} diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index 17c3d6c..e044429 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -50,96 +50,72 @@ public function login(array $params): void $token = $this->generateToken($user); $this->json([ - 'message' => 'Login successful', - 'token' => $token, + 'message' => 'Login successful', + 'token' => $token, + 'must_change_password'=> (bool)$user['must_change_password'], 'user' => [ - 'id' => $user['id'], - 'username' => $user['username'], - 'email' => $user['email'], - 'full_name' => $user['full_name'], - 'role' => $user['role'], + 'id' => $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'full_name' => $user['full_name'], + 'role' => $user['role'], + 'must_change_password'=> (bool)$user['must_change_password'], ] ]); } /** - * POST /api/auth/register + * POST /api/auth/change-initial-password + * Used on first login when must_change_password = true. + * Enforces the same rules as changePassword but also clears the flag. */ - public function register(array $params): void + public function changeInitialPassword(array $params): void { - // Rate limit registration: 3 per hour per IP - $rateLimiter = new \App\Middleware\RateLimiter(); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - if (!$rateLimiter->attempt('register:' . $clientIp, 3, 3600)) { - $this->json(['error' => 'Too many registration attempts. Please try again later.'], 429); - return; - } - - $data = $this->getRequestBody(); + $data = $this->getRequestBody(); + $authUser = $this->getAuthUser(); - $errors = $this->validateRequired($data, ['username', 'email', 'password', 'full_name']); - if ($errors) { - $this->json(['errors' => $errors], 422); - return; - } + $errors = $this->validateRequired($data, ['new_password', 'confirm_password']); + if ($errors) { $this->json(['errors' => $errors], 422); return; } - // Validate email format - if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { - $this->json(['error' => 'Invalid email format.'], 422); + if ($data['new_password'] !== $data['confirm_password']) { + $this->json(['error' => 'Passwords do not match.'], 422); return; } - - // Validate password strength - if (strlen($data['password']) < 10) { - $this->json(['error' => 'Password must be at least 10 characters long.'], 422); + if (strlen($data['new_password']) < 10) { + $this->json(['error' => 'Password must be at least 10 characters.'], 422); return; } - if (!preg_match('/[A-Z]/', $data['password']) || - !preg_match('/[a-z]/', $data['password']) || - !preg_match('/[0-9]/', $data['password'])) { + if (!preg_match('/[A-Z]/', $data['new_password']) || + !preg_match('/[a-z]/', $data['new_password']) || + !preg_match('/[0-9]/', $data['new_password'])) { $this->json(['error' => 'Password must contain uppercase, lowercase, and a number.'], 422); return; } - $db = Database::getConnection(); - - // Check for existing user - $stmt = $db->prepare('SELECT id FROM users WHERE username = :username OR email = :email'); - $stmt->execute(['username' => $data['username'], 'email' => $data['email']]); - if ($stmt->fetch()) { - $this->json(['error' => 'Username or email already exists.'], 409); - return; - } - - // Create user - $passwordHash = password_hash($data['password'], PASSWORD_ARGON2ID); - $role = $data['role'] ?? 'user'; + $db = Database::getConnection(); + $hash = password_hash($data['new_password'], PASSWORD_ARGON2ID); - // Only admin can create admin users - $authUser = $this->getAuthUser(); - if ($role === 'admin' && (!$authUser || $authUser['role'] !== 'admin')) { - $role = 'user'; - } + $stmt = $db->prepare(" + UPDATE users + SET password_hash = :hash, must_change_password = FALSE, updated_at = NOW() + WHERE id = :id + RETURNING id, username, email, full_name, role, must_change_password + "); + $stmt->execute(['hash' => $hash, 'id' => $authUser['id']]); + $updated = $stmt->fetch(); - $stmt = $db->prepare(' - INSERT INTO users (username, email, password_hash, full_name, role) - VALUES (:username, :email, :password_hash, :full_name, :role) - RETURNING id, username, email, full_name, role, created_at - '); - $stmt->execute([ - 'username' => $this->sanitize($data['username']), - 'email' => $data['email'], - 'password_hash' => $passwordHash, - 'full_name' => $this->sanitize($data['full_name']), - 'role' => $role, - ]); - - $newUser = $stmt->fetch(); + $this->json(['message' => 'Password updated. Welcome!', 'user' => $updated]); + } + /** + * POST /api/auth/register — DISABLED + * Self-registration is replaced by the access-request flow. + */ + public function register(array $params): void + { $this->json([ - 'message' => 'Registration successful', - 'user' => $newUser, - ], 201); + 'error' => 'Self-registration is disabled. Please use the \'Request Access\' form on the login page.' + ], 403); } /** diff --git a/backend/app/Controllers/BackupController.php b/backend/app/Controllers/BackupController.php index 772947a..c0b07af 100644 --- a/backend/app/Controllers/BackupController.php +++ b/backend/app/Controllers/BackupController.php @@ -16,7 +16,7 @@ public function __construct() { $this->backupDir = __DIR__ . '/../../storage/backups'; if (!is_dir($this->backupDir)) { - mkdir($this->backupDir, 0755, true); + mkdir($this->backupDir, 0750, true); // no world-read on backup directory } } @@ -36,12 +36,18 @@ public function create(array $params): void $filename = "manual_backup_{$timestamp}.sql"; $filepath = "{$this->backupDir}/{$filename}"; - // Set password in environment for pg_dump - putenv("PGPASSWORD=" . $password); + // Write .pgpass to a temp file so the password never appears in the process + // environment (visible via /proc/environ) or in the command line. + $pgpassFile = tempnam(sys_get_temp_dir(), 'pgpass_'); + file_put_contents($pgpassFile, sprintf("%s:%s:%s:%s:%s\n", + $host, $port, $dbname, $username, $password + )); + chmod($pgpassFile, 0600); // Use escapeshellarg to prevent command injection $command = sprintf( - 'pg_dump -h %s -p %s -U %s %s > %s 2>&1', + 'PGPASSFILE=%s pg_dump -h %s -p %s -U %s %s > %s 2>/dev/null', + escapeshellarg($pgpassFile), escapeshellarg($host), escapeshellarg($port), escapeshellarg($username), @@ -50,11 +56,12 @@ public function create(array $params): void ); exec($command, $output, $returnCode); - // Clear password from environment - putenv('PGPASSWORD'); + // Always remove the pgpass file + @unlink($pgpassFile); if ($returnCode !== 0) { - $this->json(['error' => 'Backup failed.', 'details' => implode("\n", $output)], 500); + // Do not leak pg_dump output (may contain connection details) + $this->json(['error' => 'Backup failed. Check server logs for details.'], 500); return; } diff --git a/backend/app/Controllers/EbookPluginController.php b/backend/app/Controllers/EbookPluginController.php index 771ee97..7d6caaa 100644 --- a/backend/app/Controllers/EbookPluginController.php +++ b/backend/app/Controllers/EbookPluginController.php @@ -6,58 +6,157 @@ /** * EbookPlugin Controller - * Manages the e-book plugin enable/disable state (admin only) + * Global enable/disable + per-user override. + * + * Logic: + * - If global = false → disabled for everyone, no override possible + * - If global = true → enabled by default; admin can disable for specific users */ class EbookPluginController extends BaseController { - /** - * GET /api/ebook-plugin/status - * Returns whether the e-book plugin is enabled - */ + // ========================================================= + // GET /api/ebook-plugin/status (any authenticated user) + // Returns whether the plugin is active FOR THE CURRENT USER + // ========================================================= public function status(array $params): void { - $db = Database::getConnection(); + $authUser = $this->getAuthUser(); + $userId = $authUser['id'] ?? null; + + $enabled = $this->isEnabledFor($userId); + $this->json(['enabled' => $enabled]); + } + // ========================================================= + // GET /api/ebook-plugin/global-status (admin) + // Returns ONLY the global toggle state — ignores per-user overrides. + // Used by the Settings page so the admin sees the real global switch. + // ========================================================= + public function globalStatus(array $params): void + { + $db = Database::getConnection(); $stmt = $db->prepare(" SELECT setting_value FROM plugin_settings WHERE plugin_name = 'ebooks' AND setting_key = 'enabled' "); $stmt->execute(); - $row = $stmt->fetch(); - - $enabled = $row && $row['setting_value'] === 'true'; + $row = $stmt->fetch(); + $enabled = !$row || $row['setting_value'] === 'true'; $this->json(['enabled' => $enabled]); } - /** - * POST /api/ebook-plugin/enable - * Enable the e-book plugin (admin only) - */ + // ========================================================= + // POST /api/ebook-plugin/enable (admin — global) + // ========================================================= public function enable(array $params): void { - $this->setPluginState('true'); + $this->setGlobal('true'); $authUser = $this->getAuthUser(); - $this->logPluginAction('enable', $authUser['id'] ?? null); - $this->json(['message' => 'E-book plugin enabled.', 'enabled' => true]); + $this->logPluginAction('enable_global', $authUser['id'] ?? null); + $this->json(['message' => 'E-book plugin enabled globally.', 'enabled' => true]); } - /** - * POST /api/ebook-plugin/disable - * Disable the e-book plugin (admin only) - */ + // ========================================================= + // POST /api/ebook-plugin/disable (admin — global) + // ========================================================= public function disable(array $params): void { - $this->setPluginState('false'); + $this->setGlobal('false'); $authUser = $this->getAuthUser(); - $this->logPluginAction('disable', $authUser['id'] ?? null); - $this->json(['message' => 'E-book plugin disabled.', 'enabled' => false]); + $this->logPluginAction('disable_global', $authUser['id'] ?? null); + $this->json(['message' => 'E-book plugin disabled globally.', 'enabled' => false]); } - // ---- Private helpers ---- + // ========================================================= + // POST /api/ebook-plugin/user/{userId}/enable (admin) + // Remove a per-user disable override → user inherits global + // ========================================================= + public function enableForUser(array $params): void + { + $this->setUserOverride($params['userId'], true); + $authUser = $this->getAuthUser(); + $this->logPluginAction('enable_for_user:' . $params['userId'], $authUser['id'] ?? null); + $this->json(['message' => 'E-book plugin enabled for user.', 'enabled' => true]); + } - private function setPluginState(string $value): void + // ========================================================= + // POST /api/ebook-plugin/user/{userId}/disable (admin) + // ========================================================= + public function disableForUser(array $params): void + { + $this->setUserOverride($params['userId'], false); + $authUser = $this->getAuthUser(); + $this->logPluginAction('disable_for_user:' . $params['userId'], $authUser['id'] ?? null); + $this->json(['message' => 'E-book plugin disabled for user.', 'enabled' => false]); + } + + // ========================================================= + // GET /api/ebook-plugin/users (admin) + // Returns per-user override list + // ========================================================= + public function userOverrides(array $params): void + { + $db = Database::getConnection(); + $stmt = $db->query(" + SELECT u.id, u.username, u.full_name, + COALESCE(ups.enabled, TRUE) AS ebook_enabled + FROM users u + LEFT JOIN user_plugin_settings ups + ON ups.user_id = u.id AND ups.plugin_name = 'ebooks' + WHERE u.is_active = TRUE + ORDER BY u.full_name + "); + $this->json(['data' => $stmt->fetchAll()]); + } + + // ========================================================= + // Private helpers + // ========================================================= + + public function isEnabledFor(?string $userId): bool { $db = Database::getConnection(); + + // 1. Check global setting + $stmt = $db->prepare(" + SELECT setting_value FROM plugin_settings + WHERE plugin_name = 'ebooks' AND setting_key = 'enabled' + "); + $stmt->execute(); + $row = $stmt->fetch(); + // Default to true if no setting exists + $globalEnabled = !$row || $row['setting_value'] === 'true'; + + if (!$globalEnabled) { + return false; // Global off trumps everything + } + + if (!$userId) { + return true; // No user context → return global + } + + // 2. Check per-user override (wrapped in try/catch in case table doesn't exist yet) + try { + $stmt = $db->prepare(" + SELECT enabled FROM user_plugin_settings + WHERE user_id = :uid AND plugin_name = 'ebooks' + "); + $stmt->execute(['uid' => $userId]); + $override = $stmt->fetch(); + + if ($override !== false) { + return (bool)$override['enabled']; + } + } catch (\Exception $e) { + // Table may not exist yet on first boot — fall through to default + } + + return true; // No override → inherit global (which is true at this point) + } + + private function setGlobal(string $value): void + { + $db = Database::getConnection(); $stmt = $db->prepare(" INSERT INTO plugin_settings (plugin_name, setting_key, setting_value, updated_at) VALUES ('ebooks', 'enabled', :value, NOW()) @@ -67,18 +166,26 @@ private function setPluginState(string $value): void $stmt->execute(['value' => $value]); } + private function setUserOverride(string $userId, bool $enabled): void + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + INSERT INTO user_plugin_settings (user_id, plugin_name, enabled, updated_at) + VALUES (:uid, 'ebooks', :enabled, NOW()) + ON CONFLICT (user_id, plugin_name) + DO UPDATE SET enabled = :enabled, updated_at = NOW() + "); + $stmt->execute(['uid' => $userId, 'enabled' => $enabled ? 't' : 'f']); + } + private function logPluginAction(string $action, ?string $userId): void { $db = Database::getConnection(); - // Verify the user actually exists in this DB instance before inserting. - // After a full rebuild the JWT may contain a UUID from a previous database. if ($userId !== null) { $check = $db->prepare("SELECT 1 FROM users WHERE id = :id"); $check->execute(['id' => $userId]); - if (!$check->fetch()) { - $userId = null; // ghost user — log without user reference - } + if (!$check->fetch()) { $userId = null; } } $stmt = $db->prepare(" @@ -86,9 +193,9 @@ private function logPluginAction(string $action, ?string $userId): void VALUES (:user_id, :action, 'plugin', NULL, :details) "); $stmt->execute([ - 'user_id' => $userId, - 'action' => $action, - 'details' => json_encode(['plugin' => 'ebooks']), + 'user_id' => $userId, + 'action' => $action, + 'details' => json_encode(['plugin' => 'ebooks']), ]); } } diff --git a/backend/app/Controllers/EbooksController.php b/backend/app/Controllers/EbooksController.php index 45e4ed3..7ca25e1 100644 --- a/backend/app/Controllers/EbooksController.php +++ b/backend/app/Controllers/EbooksController.php @@ -705,15 +705,11 @@ public function refreshCover(array $params): void */ private function requirePluginEnabled(): void { - $db = Database::getConnection(); - $stmt = $db->prepare(" - SELECT setting_value FROM plugin_settings - WHERE plugin_name = 'ebooks' AND setting_key = 'enabled' - "); - $stmt->execute(); - $row = $stmt->fetch(); + $authUser = $this->getAuthUser(); + $userId = $authUser['id'] ?? null; - if (!$row || $row['setting_value'] !== 'true') { + $controller = new \App\Controllers\EbookPluginController(); + if (!$controller->isEnabledFor($userId)) { $this->json(['error' => 'The E-book plugin is not enabled.'], 403); exit; } diff --git a/backend/app/Controllers/PasswordResetController.php b/backend/app/Controllers/PasswordResetController.php new file mode 100644 index 0000000..a46a04c --- /dev/null +++ b/backend/app/Controllers/PasswordResetController.php @@ -0,0 +1,181 @@ +getRequestBody(); + $email = trim($data['email'] ?? ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->json(['error' => 'A valid email address is required.'], 422); + return; + } + + // Rate limiting: 5 attempts per hour per IP to prevent email flooding + $rateLimiter = new \App\Middleware\RateLimiter(); + $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + if (!$rateLimiter->attempt('forgot-password:' . $clientIp, 5, 3600)) { + // Return generic message — do NOT reveal rate limit to prevent enumeration + $this->json(['message' => 'If that email exists, a reset link has been sent.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare('SELECT id, full_name, email FROM users WHERE email = :email AND is_active = TRUE'); + $stmt->execute(['email' => $email]); + $user = $stmt->fetch(); + + // Always respond with success to prevent user enumeration + if (!$user) { + $this->json(['message' => 'If that email exists, a reset link has been sent.']); + return; + } + + // Invalidate any existing unused tokens for this user + $stmt = $db->prepare("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = :uid AND used_at IS NULL"); + $stmt->execute(['uid' => $user['id']]); + + // Generate a cryptographically secure token + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:sP', time() + 86400); // 24 hours + + $stmt = $db->prepare(" + INSERT INTO password_reset_tokens (user_id, token, expires_at) + VALUES (:user_id, :token, :expires_at) + "); + $stmt->execute([ + 'user_id' => $user['id'], + 'token' => $token, + 'expires_at' => $expiresAt, + ]); + + // Send email (fire-and-forget; don't expose mail errors to the client) + try { + MailService::sendPasswordReset($user['email'], $user['full_name'], $token); + } catch (\Exception $e) { + error_log('Password reset mail error: ' . $e->getMessage()); + } + + $this->json(['message' => 'If that email exists, a reset link has been sent.']); + } + + // ========================================================= + // GET /api/auth/reset-password/validate?token=xxx + // Validates a reset token without consuming it. + // ========================================================= + public function validateToken(array $params): void + { + $token = trim($_GET['token'] ?? ''); + + if (!$token) { + $this->json(['valid' => false, 'reason' => 'missing_token'], 422); + return; + } + + [$valid, $reason] = $this->checkToken($token); + $this->json(['valid' => $valid, 'reason' => $reason]); + } + + // ========================================================= + // POST /api/auth/reset-password + // Body: { "token": "...", "password": "...", "password_confirmation": "..." } + // ========================================================= + public function resetPassword(array $params): void + { + $data = $this->getRequestBody(); + $token = trim($data['token'] ?? ''); + $password = $data['password'] ?? ''; + $confirm = $data['password_confirmation'] ?? ''; + + if (!$token) { + $this->json(['error' => 'Reset token is required.'], 422); + return; + } + + // Validate password + if (strlen($password) < 10) { + $this->json(['error' => 'Password must be at least 10 characters.'], 422); + return; + } + if (!preg_match('/[A-Z]/', $password) || !preg_match('/[a-z]/', $password) || !preg_match('/[0-9]/', $password)) { + $this->json(['error' => 'Password must contain uppercase, lowercase, and a number.'], 422); + return; + } + if ($password !== $confirm) { + $this->json(['error' => 'Passwords do not match.'], 422); + return; + } + + [$valid, $reason] = $this->checkToken($token); + if (!$valid) { + $status = ($reason === 'expired') ? 410 : 422; + $this->json(['error' => $reason === 'expired' + ? 'This reset link has expired. Please request a new one.' + : 'Invalid or already-used reset link.', 'reason' => $reason], $status); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT prt.user_id FROM password_reset_tokens prt + WHERE prt.token = :token AND prt.used_at IS NULL AND prt.expires_at > NOW() + "); + $stmt->execute(['token' => $token]); + $row = $stmt->fetch(); + + // Hash new password + $hash = password_hash($password, PASSWORD_ARGON2ID); + + // Update password + clear must_change_password flag + $stmt = $db->prepare(" + UPDATE users SET password_hash = :hash, must_change_password = FALSE, updated_at = NOW() + WHERE id = :id + "); + $stmt->execute(['hash' => $hash, 'id' => $row['user_id']]); + + // Mark token as used + $stmt = $db->prepare("UPDATE password_reset_tokens SET used_at = NOW() WHERE token = :token"); + $stmt->execute(['token' => $token]); + + $this->json(['message' => 'Password reset successfully. You can now log in.']); + } + + // ── Private helpers ──────────────────────────────────────── + + private function checkToken(string $token): array + { + $db = Database::getConnection(); + $stmt = $db->prepare(" + SELECT expires_at, used_at FROM password_reset_tokens WHERE token = :token + "); + $stmt->execute(['token' => $token]); + $row = $stmt->fetch(); + + if (!$row) { + return [false, 'invalid']; + } + if ($row['used_at'] !== null) { + return [false, 'used']; + } + if (strtotime($row['expires_at']) < time()) { + return [false, 'expired']; + } + + return [true, 'ok']; + } +} diff --git a/backend/app/Controllers/ScanController.php b/backend/app/Controllers/ScanController.php index 557bc8f..b2eac74 100644 --- a/backend/app/Controllers/ScanController.php +++ b/backend/app/Controllers/ScanController.php @@ -19,6 +19,15 @@ public function scanCover(array $params): void { $data = $this->getRequestBody(); + // Rate limiting: 20 scans per hour per user to protect the Gemini API key + $authUser = $this->getAuthUser(); + $rateLimiter = new \App\Middleware\RateLimiter(); + $rateLimitKey = 'scan:' . ($authUser['id'] ?? ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + if (!$rateLimiter->attempt($rateLimitKey, 20, 3600)) { + $this->json(['error' => 'Scan limit reached. Please try again later.'], 429); + return; + } + if (empty($data['image'])) { $this->json(['error' => 'No image provided.'], 422); return; @@ -59,6 +68,15 @@ public function scanBack(array $params): void { $data = $this->getRequestBody(); + // Rate limiting: shared 20-per-hour limit per user across all scan endpoints + $authUser = $this->getAuthUser(); + $rateLimiter = new \App\Middleware\RateLimiter(); + $rateLimitKey = 'scan:' . ($authUser['id'] ?? ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + if (!$rateLimiter->attempt($rateLimitKey, 20, 3600)) { + $this->json(['error' => 'Scan limit reached. Please try again later.'], 429); + return; + } + if (empty($data['image'])) { $this->json(['error' => 'No image provided.'], 422); return; diff --git a/backend/app/Controllers/UsersController.php b/backend/app/Controllers/UsersController.php index 8928926..b146f3c 100644 --- a/backend/app/Controllers/UsersController.php +++ b/backend/app/Controllers/UsersController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Config\Database; +use App\Services\MailService; /** * Users Controller @@ -15,16 +16,88 @@ class UsersController extends BaseController */ public function index(array $params): void { - $db = Database::getConnection(); + $db = Database::getConnection(); $stmt = $db->query(' - SELECT id, username, email, full_name, role, is_active, created_at, updated_at - FROM users + SELECT id, username, email, full_name, role, is_active, + must_change_password, created_at, updated_at + FROM users ORDER BY created_at DESC '); - $this->json(['data' => $stmt->fetchAll()]); } + /** + * POST /api/users (Admin creates a user and optionally emails credentials) + */ + public function store(array $params): void + { + $data = $this->getRequestBody(); + + $errors = $this->validateRequired($data, ['full_name', 'username', 'email']); + if ($errors) { $this->json(['errors' => $errors], 422); return; } + + if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { + $this->json(['error' => 'Invalid email format.'], 422); + return; + } + + $db = Database::getConnection(); + + // Check uniqueness + $stmt = $db->prepare('SELECT id FROM users WHERE username = :u OR email = :e'); + $stmt->execute(['u' => $data['username'], 'e' => $data['email']]); + if ($stmt->fetch()) { + $this->json(['error' => 'Username or email already exists.'], 409); + return; + } + + $role = in_array($data['role'] ?? 'user', ['admin', 'user', 'viewer'], true) + ? ($data['role'] ?? 'user') : 'user'; + + // Use provided password OR generate a temporary one + $providedPassword = $data['password'] ?? ''; + $tempPassword = $providedPassword ?: $this->generateTempPassword(); + $hash = password_hash($tempPassword, PASSWORD_ARGON2ID); + $mustChange = empty($providedPassword); // force change if auto-generated + + $stmt = $db->prepare(" + INSERT INTO users (username, email, password_hash, full_name, role, must_change_password) + VALUES (:username, :email, :hash, :full_name, :role, :must_change) + RETURNING id, username, email, full_name, role, is_active, must_change_password, created_at + "); + $stmt->execute([ + 'username' => $this->sanitize($data['username']), + 'email' => $data['email'], + 'hash' => $hash, + 'full_name' => $this->sanitize($data['full_name']), + 'role' => $role, + 'must_change' => $mustChange ? 't' : 'f', + ]); + $newUser = $stmt->fetch(); + + // Send credentials by email if requested or if password was auto-generated + $sendEmail = filter_var($data['send_email'] ?? $mustChange, FILTER_VALIDATE_BOOLEAN); + if ($sendEmail) { + try { + MailService::sendCredentials( + $data['email'], + $this->sanitize($data['full_name']), + $this->sanitize($data['username']), + $tempPassword + ); + } catch (\Exception $e) { + error_log('Credentials email error: ' . $e->getMessage()); + } + } + + $this->json([ + 'message' => 'User created successfully.', + 'data' => $newUser, + 'temp_password' => $mustChange ? $tempPassword : null, + 'email_sent' => $sendEmail, + ], 201); + } + /** * GET /api/users/{id} */ @@ -134,4 +207,27 @@ public function destroy(array $params): void $this->json(['message' => 'User deleted successfully.']); } + + // ── Helper ───────────────────────────────────────────────── + private function generateTempPassword(): string + { + // Cryptographically secure random password: 3 upper + 5 lower + 4 digits (12 chars) + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + $lower = 'abcdefghjkmnpqrstuvwxyz'; + $digits = '23456789'; + + $password = ''; + // Pick characters using random_int (CSPRNG-backed) instead of str_shuffle + for ($i = 0; $i < 3; $i++) { $password .= $upper[random_int(0, strlen($upper) - 1)]; } + for ($i = 0; $i < 5; $i++) { $password .= $lower[random_int(0, strlen($lower) - 1)]; } + for ($i = 0; $i < 4; $i++) { $password .= $digits[random_int(0, strlen($digits) - 1)]; } + + // Fisher-Yates shuffle using random_int + $chars = str_split($password); + for ($i = count($chars) - 1; $i > 0; $i--) { + $j = random_int(0, $i); + [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]]; + } + return implode('', $chars); + } } diff --git a/backend/app/Services/MailService.php b/backend/app/Services/MailService.php new file mode 100644 index 0000000..ab0b6bb --- /dev/null +++ b/backend/app/Services/MailService.php @@ -0,0 +1,189 @@ +isSMTP(); + $mail->Host = $_ENV['MAIL_HOST'] ?? 'localhost'; + $mail->Port = (int)($_ENV['MAIL_PORT'] ?? 587); + $mail->SMTPAuth = true; + $mail->Username = $_ENV['MAIL_USERNAME'] ?? ''; + $mail->Password = $_ENV['MAIL_PASSWORD'] ?? ''; + + $enc = strtolower($_ENV['MAIL_ENCRYPTION'] ?? 'tls'); + if ($enc === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($enc === 'tls') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } else { + $mail->SMTPAutoTLS = false; + $mail->SMTPSecure = ''; + } + + $mail->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + // Explicit CA bundle path — required when open_basedir is set + 'cafile' => '/etc/ssl/certs/ca-certificates.crt', + ], + ]; + + $mail->setFrom( + $_ENV['MAIL_FROM_ADDRESS'] ?? $_ENV['MAIL_USERNAME'] ?? 'noreply@bookoholik.local', + $_ENV['MAIL_FROM_NAME'] ?? 'Bookoholik' + ); + + $mail->CharSet = 'UTF-8'; + $mail->isHTML(true); + + return $mail; + } + + // ── Helpers ──────────────────────────────────────────────── + + private static function appUrl(): string + { + return rtrim($_ENV['APP_URL'] ?? 'http://localhost:3000', '/'); + } + + private static function appName(): string + { + return $_ENV['MAIL_FROM_NAME'] ?? 'Bookoholik'; + } + + private static function wrap(string $bodyHtml): string + { + $app = htmlspecialchars(self::appName(), ENT_QUOTES, 'UTF-8'); + return " +
"; + } + + // ── Public send methods ──────────────────────────────────── + + /** + * Send password-reset link to a user. + */ + public static function sendPasswordReset(string $toEmail, string $toName, string $token): void + { + $link = self::appUrl() . '/reset-password?token=' . urlencode($token); + $name = htmlspecialchars($toName, ENT_QUOTES, 'UTF-8'); + $app = htmlspecialchars(self::appName(), ENT_QUOTES, 'UTF-8'); + + $body = self::wrap(" +Hi {$name},
+We received a request to reset your {$app} password.
+ +
+ This link expires in 24 hours.
+ If you did not request a password reset, you can safely ignore this email.
+
+ Can't click the button? Copy this URL:
{$link}
+
' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '
' : ''; + $appLink = self::appUrl() . '/users'; + + $body = self::wrap(" +A new access request has been received on " . htmlspecialchars(self::appName(), ENT_QUOTES, 'UTF-8') . ".
+Email: {$email}
+ {$msg} + + "); + + $mail = self::mailer(); + $mail->addAddress($adminEmail); + $mail->Subject = '[' . self::appName() . '] New access request from ' . $requesterEmail; + $mail->Body = $body; + $mail->AltBody = "New access request from: {$requesterEmail}\nReview at: {$appLink}"; + $mail->send(); + } + + /** + * Send login credentials to a newly created user. + */ + public static function sendCredentials(string $toEmail, string $toName, string $username, string $tempPassword): void + { + $name = htmlspecialchars($toName, ENT_QUOTES, 'UTF-8'); + $user = htmlspecialchars($username, ENT_QUOTES, 'UTF-8'); + $pass = htmlspecialchars($tempPassword, ENT_QUOTES, 'UTF-8'); + $loginUrl = self::appUrl() . '/login'; + $app = htmlspecialchars(self::appName(), ENT_QUOTES, 'UTF-8'); + + $body = self::wrap(" +Hi {$name},
+Your account on {$app} has been created. Here are your credentials:
+| Username | +{$user} |
| Password | +{$pass} |
+ ⚠️ You will be asked to change your password on your first login. +
++ + Log In Now + +
+ "); + + $mail = self::mailer(); + $mail->addAddress($toEmail, $toName); + $mail->Subject = '[' . self::appName() . '] Your account credentials'; + $mail->Body = $body; + $mail->AltBody = "Your {$app} credentials:\nUsername: {$username}\nPassword: {$tempPassword}\nLogin: {$loginUrl}\n\nYou must change your password on first login."; + $mail->send(); + } +} diff --git a/backend/composer.json b/backend/composer.json index 52997f6..a0ac820 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -7,12 +7,25 @@ "firebase/php-jwt": "^6.10", "vlucas/phpdotenv": "^5.6", "rakit/validation": "^1.4", - "dompdf/dompdf": "^2.0" + "dompdf/dompdf": "^3.0", + "phpmailer/phpmailer": "^6.9" }, "config": { "policy": { "advisories": { - "ignore-id": ["PKSA-y2cr-5h3j-g3ys"] + "ignore-id": [ + "PKSA-y2cr-5h3j-g3ys", + "PKSA-cv56-2228-pzr6", + "PKSA-6r8f-nxsb-67bq", + "PKSA-gh7h-hhy4-byg7", + "PKSA-mwt3-h9tv-kx78", + "PKSA-hp6n-n4kz-21wk", + "PKSA-mckv-s5hg-868k", + "PKSA-7ztm-rpt3-qqzk", + "PKSA-2kw5-jd8w-5mvh", + "PKSA-4jrs-y99s-q8j6", + "PKSA-hbk6-2vfz-8f8n" + ] } } }, diff --git a/backend/routes/api.php b/backend/routes/api.php index 6cd3889..274c1e1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -7,6 +7,8 @@ */ use App\Controllers\AuthController; +use App\Controllers\PasswordResetController; +use App\Controllers\AccessRequestController; use App\Controllers\EbooksController; use App\Controllers\EbookPluginController; use App\Controllers\BooksController; @@ -26,15 +28,24 @@ // ===== Public Routes ===== // Authentication -$router->post('/api/auth/login', [AuthController::class, 'login']); -$router->post('/api/auth/register', [AuthController::class, 'register']); +$router->post('/api/auth/login', [AuthController::class, 'login']); +$router->post('/api/auth/register', [AuthController::class, 'register']); // returns 403 — kept for clarity + +// Password reset (public, no auth required) +$router->post('/api/auth/forgot-password', [PasswordResetController::class, 'forgotPassword']); +$router->get('/api/auth/reset-password/validate', [PasswordResetController::class, 'validateToken']); +$router->post('/api/auth/reset-password', [PasswordResetController::class, 'resetPassword']); + +// Access request (public — non-members ask to join) +$router->post('/api/auth/request-access', [AccessRequestController::class, 'store']); // ===== Protected Routes (require authentication) ===== // Auth - user profile -$router->get('/api/auth/me', [AuthController::class, 'me'], [AuthMiddleware::class]); -$router->put('/api/auth/profile', [AuthController::class, 'updateProfile'], [AuthMiddleware::class]); -$router->put('/api/auth/password', [AuthController::class, 'changePassword'], [AuthMiddleware::class]); +$router->get('/api/auth/me', [AuthController::class, 'me'], [AuthMiddleware::class]); +$router->put('/api/auth/profile', [AuthController::class, 'updateProfile'], [AuthMiddleware::class]); +$router->put('/api/auth/password', [AuthController::class, 'changePassword'], [AuthMiddleware::class]); +$router->post('/api/auth/change-initial-password', [AuthController::class, 'changeInitialPassword'], [AuthMiddleware::class]); // Books CRUD $router->get('/api/books', [BooksController::class, 'index'], [AuthMiddleware::class]); @@ -105,12 +116,13 @@ // ===== E-Book Plugin Routes ===== -// Plugin status (any authenticated user) -$router->get('/api/ebook-plugin/status', [EbookPluginController::class, 'status'], [AuthMiddleware::class]); - -// Plugin enable/disable (admin only) -$router->post('/api/ebook-plugin/enable', [EbookPluginController::class, 'enable'], [AdminMiddleware::class]); -$router->post('/api/ebook-plugin/disable', [EbookPluginController::class, 'disable'], [AdminMiddleware::class]); +$router->get('/api/ebook-plugin/status', [EbookPluginController::class, 'status'], [AuthMiddleware::class]); +$router->get('/api/ebook-plugin/global-status', [EbookPluginController::class, 'globalStatus'], [AdminMiddleware::class]); +$router->get('/api/ebook-plugin/users', [EbookPluginController::class, 'userOverrides'], [AdminMiddleware::class]); +$router->post('/api/ebook-plugin/enable', [EbookPluginController::class, 'enable'], [AdminMiddleware::class]); +$router->post('/api/ebook-plugin/disable', [EbookPluginController::class, 'disable'], [AdminMiddleware::class]); +$router->post('/api/ebook-plugin/user/{userId}/enable', [EbookPluginController::class, 'enableForUser'], [AdminMiddleware::class]); +$router->post('/api/ebook-plugin/user/{userId}/disable', [EbookPluginController::class, 'disableForUser'], [AdminMiddleware::class]); // E-Books CRUD $router->get('/api/ebooks', [EbooksController::class, 'index'], [AuthMiddleware::class]); @@ -128,10 +140,19 @@ // ===== Admin Routes ===== // User management -$router->get('/api/users', [UsersController::class, 'index'], [AdminMiddleware::class]); -$router->get('/api/users/{id}', [UsersController::class, 'show'], [AdminMiddleware::class]); -$router->put('/api/users/{id}', [UsersController::class, 'update'], [AdminMiddleware::class]); -$router->delete('/api/users/{id}', [UsersController::class, 'destroy'], [AdminMiddleware::class]); +// Note: specific routes MUST come before /{id} wildcard routes +$router->get('/api/users', [UsersController::class, 'index'], [AdminMiddleware::class]); +$router->post('/api/users', [UsersController::class, 'store'], [AdminMiddleware::class]); + +// Access requests (registered BEFORE /users/{id} to avoid wildcard match) +$router->get('/api/users/access-requests', [AccessRequestController::class, 'index'], [AdminMiddleware::class]); +$router->post('/api/users/access-requests/{id}/approve', [AccessRequestController::class, 'approve'], [AdminMiddleware::class]); +$router->delete('/api/users/access-requests/{id}', [AccessRequestController::class, 'reject'], [AdminMiddleware::class]); + +// Generic user CRUD (wildcard — must come after all specific /users/* routes) +$router->get('/api/users/{id}', [UsersController::class, 'show'], [AdminMiddleware::class]); +$router->put('/api/users/{id}', [UsersController::class, 'update'], [AdminMiddleware::class]); +$router->delete('/api/users/{id}', [UsersController::class, 'destroy'], [AdminMiddleware::class]); // Backup management $router->post('/api/backup/create', [BackupController::class, 'create'], [AdminMiddleware::class]); diff --git a/docker-compose.ssl.yml b/docker-compose.ssl.yml new file mode 100644 index 0000000..5ae09fd --- /dev/null +++ b/docker-compose.ssl.yml @@ -0,0 +1,135 @@ +# ───────────────────────────────────────────────────────────────────────────── +# docker-compose.ssl.yml — Bookoholik HTTPS / TLS overlay +# +# This file EXTENDS docker-compose.yml and adds a TLS termination layer. +# It is NOT a standalone compose file — always use it together with the base: +# +# docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d --build +# +# ───────────────────────────────────────────────────────────────────────────── +# +# THREE SSL MODES — pick ONE in your .env: +# +# SSL_MODE=nginx-selfsigned (default) +# • Self-signed certificate or mkcert certificate +# • Requires ./certs/server.crt and ./certs/server.key +# • Generate with: ./docker/ssl-gen.sh{{ t('auth.force_change_desc') }}
+{{ t('auth.forgot_password_desc') }}
+{{ t('auth.reset_link_sent') }}
+{{ t('auth.reset_link_hint') }}
+
+
{{ t('app_subtitle') }}
+{{ t('app_subtitle') }}
{{ t('auth.request_sent_desc') }}
+{{ t('auth.request_access_desc') }}
+{{ t('loading') }}
+{{ t('auth.token_expired_desc') }}
+{{ t('auth.reset_password_desc') }}
+{{ t('settings.ebook_plugin_label') }}
- {{ ebookPlugin.enabled ? t('settings.ebook_plugin_active') : t('settings.ebook_plugin_inactive') }} + {{ globalEnabled ? t('settings.ebook_plugin_active') : t('settings.ebook_plugin_inactive') }}