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 " +
+
+ {$app} +
+
+ {$bodyHtml} +
+
+ {$app} — Home Library Management +
+
"; + } + + // ── 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.

+

+ + Reset 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} +

+ "); + + $mail = self::mailer(); + $mail->addAddress($toEmail, $toName); + $mail->Subject = '[' . self::appName() . '] Reset your password'; + $mail->Body = $body; + $mail->AltBody = "Reset your password: {$link}\n\nThis link expires in 24 hours."; + $mail->send(); + } + + /** + * Notify the admin of a new access request. + */ + public static function sendAccessRequestNotification(string $adminEmail, string $requesterEmail, ?string $message): void + { + $email = htmlspecialchars($requesterEmail, ENT_QUOTES, 'UTF-8'); + $msg = $message ? '

' . 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} +

+ + Review in Admin Panel + +

+ "); + + $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 +# • Or with mkcert: mkcert -key-file ./certs/server.key \ +# -cert-file ./certs/server.crt \ +# localhost 127.0.0.1 +# +# SSL_MODE=nginx-letsencrypt +# • Bring-your-own Let's Encrypt certificate (certbot / manual) +# • Mount your cert files by setting LETSENCRYPT_CERT_DIR in .env +# (defaults to /etc/letsencrypt/live/${SSL_DOMAIN}) +# +# SSL_MODE=caddy +# • Caddy reverse proxy with automatic Let's Encrypt certificate +# • Requires a real public domain pointing to this server +# • Ports 80 + 443 must be reachable from the internet +# • Set SSL_DOMAIN=bookoholik.yourdomain.com in .env +# +# ───────────────────────────────────────────────────────────────────────────── +# +# QUICK START (self-signed, home LAN): +# +# 1. cp .env.ssl.example .env.ssl # copy SSL env template +# # edit .env.ssl — set SSL_DOMAIN to your LAN IP or hostname +# +# 2. ./docker/ssl-gen.sh 192.168.1.12 # generate self-signed cert +# +# 3. # Update base .env: +# # CORS_ORIGIN=https://192.168.1.12 +# # API_URL=https://192.168.1.12/api +# # APP_URL=https://192.168.1.12 +# # NGINX_API_URL=https://192.168.1.12 +# +# 4. docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.ssl.yml \ +# up -d --build +# +# 5. Open https://192.168.1.12 in your browser. +# (Accept the self-signed cert warning, or trust it — see wiki/HTTPS-Setup.md) +# +# ───────────────────────────────────────────────────────────────────────────── + +# Load SSL-specific env vars (SSL_MODE, SSL_DOMAIN, LETSENCRYPT_CERT_DIR, etc.) +# These are declared in .env.ssl.example / .env.ssl +# The base .env is still loaded by docker compose automatically. + +services: + + # ── nginx SSL proxy (SSL_MODE=nginx-selfsigned or nginx-letsencrypt) ─────── + ssl-proxy: + image: nginx:alpine + container_name: bookoholik_ssl_proxy + restart: unless-stopped + profiles: + - nginx-ssl # activated when SSL_MODE starts with "nginx-" + ports: + - "80:80" + - "443:443" + environment: + SSL_DOMAIN: ${SSL_DOMAIN:-localhost} + volumes: + # nginx config template — envsubst fills in ${SSL_DOMAIN} at startup + - ./docker/nginx-ssl.conf:/etc/nginx/templates/default.conf.template:ro + # Self-signed / mkcert cert (SSL_MODE=nginx-selfsigned) + - ${SSL_CERT_FILE:-./certs/server.crt}:/etc/ssl/certs/bookoholik.crt:ro + - ${SSL_KEY_FILE:-./certs/server.key}:/etc/ssl/private/bookoholik.key:ro + depends_on: + - frontend + - backend + networks: + - library_network + deploy: + resources: + limits: + memory: 64M + cpus: '0.25' + + # ── Caddy (SSL_MODE=caddy — automatic Let's Encrypt) ─────────────────────── + caddy: + image: caddy:2-alpine + container_name: bookoholik_caddy + restart: unless-stopped + profiles: + - caddy # activated when SSL_MODE=caddy + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 (QUIC) + environment: + SSL_DOMAIN: ${SSL_DOMAIN:?SSL_DOMAIN is required for caddy mode} + volumes: + - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - frontend + - backend + networks: + - library_network + deploy: + resources: + limits: + memory: 128M + cpus: '0.5' + + # ── When SSL proxy is active, the direct ports of frontend/backend are ────── + # ── no longer needed (traffic goes through the proxy). Override them here. ── + + frontend: + # Remove direct port exposure — all traffic comes through the SSL proxy + ports: !reset [] + + backend: + # Remove direct port exposure — all traffic comes through the SSL proxy + ports: !reset [] + +volumes: + caddy_data: + driver: local + caddy_config: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 7ef5e8a..dbce1c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,16 @@ services: CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} GEMINI_API_KEY: ${GEMINI_API_KEY:-} GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.0-flash} + # SMTP / Email + MAIL_DRIVER: ${MAIL_DRIVER:-smtp} + MAIL_HOST: ${MAIL_HOST:-smtp.gmail.com} + MAIL_PORT: ${MAIL_PORT:-587} + MAIL_ENCRYPTION: ${MAIL_ENCRYPTION:-tls} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Bookoholik} + APP_URL: ${APP_URL:-http://localhost:3000} ports: - "${BACKEND_PORT:-8080}:80" volumes: @@ -71,6 +81,9 @@ services: dockerfile: docker/Dockerfile.frontend args: VITE_API_URL: ${API_URL:-http://localhost:8080/api} + # NGINX_API_URL: origin only (no /api path) used in the CSP connect-src header. + # Derived from API_URL by stripping /api suffix in .env, or set explicitly. + NGINX_API_URL: ${NGINX_API_URL:-http://localhost:8080} container_name: bookoholik_frontend restart: unless-stopped ports: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..1446e82 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,58 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Caddyfile — Let's Encrypt automatic HTTPS (Option C) +# +# Used by docker-compose.ssl.yml when SSL_MODE=caddy. +# Replace "bookoholik.yourdomain.com" with your real domain, or set the +# SSL_DOMAIN variable in .env (docker-compose.ssl.yml reads it automatically). +# +# Caddy obtains and renews certificates from Let's Encrypt automatically. +# Ports 80 and 443 must be reachable from the internet. +# ───────────────────────────────────────────────────────────────────────────── + +{env.SSL_DOMAIN} { + + # ── Backend API (/api/* → backend container) ───────────────────────────── + handle /api/* { + reverse_proxy backend:80 { + # Pass real client info to PHP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + } + # Allow large ebook uploads (100 MB + headroom) + request_body { + max_size 110MB + } + } + + # ── Frontend SPA (everything else → frontend container) ────────────────── + reverse_proxy frontend:80 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto https + } + + # ── Security headers ───────────────────────────────────────────────────── + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=()" + X-Permitted-Cross-Domain-Policies "none" + # Remove server identification + -Server + } + + # ── TLS hardening ──────────────────────────────────────────────────────── + tls { + protocols tls1.2 tls1.3 + } + + # ── Logging ────────────────────────────────────────────────────────────── + log { + output stderr + format console + level WARN + } +} diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index de43d77..ef911ff 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -39,6 +39,7 @@ RUN echo 'post_max_size = 110M' > /usr/local/etc/php/conf.d/uploads.i # Security hardening RUN echo 'expose_php = Off' > /usr/local/etc/php/conf.d/security.ini && \ + echo 'allow_url_fopen = Off' >> /usr/local/etc/php/conf.d/security.ini && \ echo 'allow_url_include = Off' >> /usr/local/etc/php/conf.d/security.ini && \ echo 'session.cookie_httponly = 1' >> /usr/local/etc/php/conf.d/security.ini && \ echo 'session.cookie_samesite = Strict' >> /usr/local/etc/php/conf.d/security.ini && \ diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend index e06d0de..d45d544 100644 --- a/docker/Dockerfile.frontend +++ b/docker/Dockerfile.frontend @@ -15,16 +15,29 @@ RUN npm run build # Production stage FROM nginx:alpine +# Install envsubst (part of gettext) for runtime config templating +RUN apk add --no-cache gettext + # Copy built assets COPY --from=build /app/dist /usr/share/nginx/html -# Nginx configuration for Vue.js SPA -COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +# Copy nginx config template (uses ${NGINX_API_URL} placeholder) +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf.template # Fix permissions for non-root nginx RUN chown -R nginx:nginx /usr/share/nginx/html \ && chmod -R 555 /usr/share/nginx/html +# The API URL used in the CSP connect-src (same as VITE_API_URL but host-only). +# Default matches local dev. Override at build time: --build-arg NGINX_API_URL=https://api.example.com +ARG NGINX_API_URL=http://localhost:8080 +ENV NGINX_API_URL=${NGINX_API_URL} + +# Substitute env vars into the nginx config at build time +RUN envsubst '${NGINX_API_URL}' < /etc/nginx/conf.d/default.conf.template \ + > /etc/nginx/conf.d/default.conf \ + && rm /etc/nginx/conf.d/default.conf.template + EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/apache.conf b/docker/apache.conf index b6d68f7..62d16ff 100644 --- a/docker/apache.conf +++ b/docker/apache.conf @@ -4,11 +4,16 @@ AllowOverride All Require all granted Options -Indexes +FollowSymLinks - # Restrict PHP file access to app root + temp dir only (web requests only) - php_admin_value open_basedir "/var/www/html:/tmp" + # Restrict PHP file access to app root + temp dir + SSL certs (for SMTP TLS) + # open_basedir blocks PHP's SSL stream from reading CA certificates outside this list + php_admin_value open_basedir "/var/www/html:/tmp:/etc/ssl/certs:/etc/ssl" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "DENY" Header always set X-XSS-Protection "0" Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + Header always set X-Permitted-Cross-Domain-Policies "none" + # API responses are JSON — a restrictive CSP prevents any accidental HTML rendering + Header always set Content-Security-Policy "default-src 'none'" diff --git a/docker/init.sql b/docker/init.sql index 2103893..d0a8967 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -13,6 +13,7 @@ CREATE TABLE users ( full_name VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user', 'viewer')), is_active BOOLEAN DEFAULT TRUE, + must_change_password BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -169,7 +170,7 @@ CREATE TABLE IF NOT EXISTS plugin_settings ( ); INSERT INTO plugin_settings (plugin_name, setting_key, setting_value) -VALUES ('ebooks', 'enabled', 'false') +VALUES ('ebooks', 'enabled', 'true') ON CONFLICT (plugin_name, setting_key) DO NOTHING; -- E-Books table @@ -214,6 +215,38 @@ CREATE TABLE IF NOT EXISTS ebook_reading_progress ( CREATE INDEX IF NOT EXISTS idx_ebook_progress_user ON ebook_reading_progress(user_id); CREATE INDEX IF NOT EXISTS idx_ebook_progress_ebook ON ebook_reading_progress(ebook_id); +-- Password reset tokens (time-limited, one-use) +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token); +CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(user_id); + +-- Access requests (non-members requesting an account) +CREATE TABLE IF NOT EXISTS access_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL, + message TEXT, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status); + +-- Per-user plugin settings (admin can disable ebook plugin for specific users) +CREATE TABLE IF NOT EXISTS user_plugin_settings ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plugin_name VARCHAR(100) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (user_id, plugin_name) +); + -- Indexes for performance CREATE INDEX idx_books_title ON books USING gin(to_tsvector('simple', title)); CREATE INDEX idx_books_author ON books USING gin(to_tsvector('simple', author)); @@ -255,6 +288,10 @@ INSERT INTO genres (name, name_ar, name_fr) VALUES ('Islamic Studies', 'دراسات إسلامية', 'Études islamiques'); -- Insert default admin user --- DEFAULT PASSWORD: Admin1234! (MUST be changed on first login) -INSERT INTO users (username, email, password_hash, full_name, role) VALUES - ('admin', 'admin@homelibrary.local', '$2y$12$/COW4ljVYn.YoMpWPrxtgu4dyeNo73abitkJiqab8LEUn.qqq3D3e', 'مدير المكتبة', 'admin'); +-- Password: Admin1234! (bcrypt, cost 12). Must be changed on first login. +-- password_verify() handles both bcrypt and argon2id transparently. +INSERT INTO users (username, email, password_hash, full_name, role, must_change_password) VALUES + ('admin', 'admin@homelibrary.local', + '$2y$12$/COW4ljVYn.YoMpWPrxtgu4dyeNo73abitkJiqab8LEUn.qqq3D3e', + 'مدير المكتبة', 'admin', TRUE) + ON CONFLICT (username) DO NOTHING; diff --git a/docker/nginx-ssl.conf b/docker/nginx-ssl.conf new file mode 100644 index 0000000..25cffa7 --- /dev/null +++ b/docker/nginx-ssl.conf @@ -0,0 +1,78 @@ +# ───────────────────────────────────────────────────────────────────────────── +# nginx-ssl.conf — HTTPS reverse proxy for Bookoholik +# +# Used by docker-compose.ssl.yml (Option A: self-signed / Option B: mkcert). +# This file is a template: ${NGINX_API_URL} and ${SSL_DOMAIN} are substituted +# at container start-up by the entrypoint of the ssl-proxy service. +# +# DO NOT use for plain-HTTP deployments — use docker-compose.yml instead. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Redirect all HTTP → HTTPS ──────────────────────────────────────────────── +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +# ── HTTPS main server ──────────────────────────────────────────────────────── +server { + listen 443 ssl; + server_name ${SSL_DOMAIN}; + + # ── TLS certificate paths (mounted as Docker volumes) ──────────────────── + ssl_certificate /etc/ssl/certs/bookoholik.crt; + ssl_certificate_key /etc/ssl/private/bookoholik.key; + + # ── TLS hardening ──────────────────────────────────────────────────────── + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # ── Security headers ───────────────────────────────────────────────────── + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + # CSP: allow connect-src to the backend origin (same host, /api path goes to backend) + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' blob:; worker-src 'self' blob:;" always; + + server_tokens off; + + # ── Gzip ───────────────────────────────────────────────────────────────── + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/wasm; + + # ── Backend API proxy (/api/* → backend container) ─────────────────────── + location /api/ { + proxy_pass http://backend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + + # Large file support (ebook uploads up to 100 MB + headroom) + client_max_body_size 110M; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_request_buffering off; + } + + # ── Frontend SPA proxy (everything else → frontend container) ──────────── + location / { + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + } +} diff --git a/docker/nginx.conf b/docker/nginx.conf index e597462..1c10f29 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -24,7 +24,8 @@ server { # blob: added to img-src — required for cover images loaded as authenticated blob URLs # worker-src 'self' blob: — required for the PDF.js web worker (.mjs bundled locally) # 'unsafe-eval' in script-src — required by vue-i18n message compiler at runtime - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:8080 http://192.168.1.12:8080 blob:; worker-src 'self' blob:;" always; + # ${NGINX_API_URL} — injected at build time via envsubst from Dockerfile.frontend NGINX_API_URL arg + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${NGINX_API_URL} blob:; worker-src 'self' blob:;" always; # Hide nginx version server_tokens off; diff --git a/docker/ssl-gen.sh b/docker/ssl-gen.sh new file mode 100755 index 0000000..5f7dccf --- /dev/null +++ b/docker/ssl-gen.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────────── +# ssl-gen.sh — Self-signed TLS certificate generator for Bookoholik +# +# Usage: +# ./docker/ssl-gen.sh # uses localhost + 127.0.0.1 +# ./docker/ssl-gen.sh 192.168.1.12 # LAN IP +# ./docker/ssl-gen.sh 192.168.1.12 myhome.local # IP + hostname +# +# Certificates are placed in: ./certs/server.crt and ./certs/server.key +# +# After running this script, start the app with: +# docker compose -f docker-compose.ssl.yml up -d --build +# ───────────────────────────────────────────────────────────────────────────── +set -e + +CERTS_DIR="$(cd "$(dirname "$0")/.." && pwd)/certs" +mkdir -p "${CERTS_DIR}" + +# ── Collect SANs from arguments ────────────────────────────────────────────── +IP1="${1:-127.0.0.1}" +EXTRA="${2:-}" + +SAN="IP:${IP1},IP:127.0.0.1" +CN="${IP1}" + +if [ -n "${EXTRA}" ]; then + # If the extra arg looks like an IP, add as IP SAN; otherwise as DNS SAN + case "${EXTRA}" in + [0-9]*.[0-9]*.[0-9]*.[0-9]*) + SAN="${SAN},IP:${EXTRA}" + ;; + *) + SAN="${SAN},DNS:${EXTRA},DNS:localhost" + CN="${EXTRA}" + ;; + esac +else + SAN="${SAN},DNS:localhost" +fi + +echo "" +echo "Generating self-signed certificate..." +echo " CN : ${CN}" +echo " SAN : ${SAN}" +echo " Dir : ${CERTS_DIR}" +echo "" + +openssl req -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -days 3650 \ + -nodes \ + -keyout "${CERTS_DIR}/server.key" \ + -out "${CERTS_DIR}/server.crt" \ + -subj "/CN=${CN}/O=Bookoholik/OU=Home Library" \ + -addext "subjectAltName=${SAN}" + +chmod 600 "${CERTS_DIR}/server.key" +chmod 644 "${CERTS_DIR}/server.crt" + +echo "" +echo "✅ Certificate generated:" +echo " ${CERTS_DIR}/server.crt" +echo " ${CERTS_DIR}/server.key" +echo "" +echo "Next steps:" +echo " 1. Copy .env.ssl.example to .env and fill in your values." +echo " 2. Start the SSL stack:" +echo " docker compose -f docker-compose.ssl.yml up -d --build" +echo "" +echo "To trust this certificate on macOS:" +echo " sudo security add-trusted-cert -d -r trustRoot \\" +echo " -k /Library/Keychains/System.keychain ${CERTS_DIR}/server.crt" +echo "" +echo "For iOS: email server.crt to your device → install as profile." diff --git a/docker/v0.3.0-migration.sql b/docker/v0.3.0-migration.sql new file mode 100644 index 0000000..a0458e6 --- /dev/null +++ b/docker/v0.3.0-migration.sql @@ -0,0 +1,49 @@ +-- v0.3.0 migrations: auth features + per-user plugin settings + +-- Force password change on first login +ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE; + +-- Password reset tokens (time-limited, one-use) +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token); +CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(user_id); + +-- Access requests (non-members asking for an account) +CREATE TABLE IF NOT EXISTS access_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL, + message TEXT, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status); +CREATE INDEX IF NOT EXISTS idx_access_requests_email ON access_requests(email); + +-- Per-user plugin overrides (admin can disable for specific users) +CREATE TABLE IF NOT EXISTS user_plugin_settings ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plugin_name VARCHAR(100) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (user_id, plugin_name) +); + +-- Enable ebook plugin by default for all (was previously 'false') +UPDATE plugin_settings +SET setting_value = 'true' +WHERE plugin_name = 'ebooks' AND setting_key = 'enabled'; + +-- Admin user must change password on first login (if still using default password) +-- Only set if the admin's password is still the shipped default hash +UPDATE users +SET must_change_password = TRUE +WHERE username = 'admin' + AND password_hash = '$2y$12$/COW4ljVYn.YoMpWPrxtgu4dyeNo73abitkJiqab8LEUn.qqq3D3e'; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 874e2c6..12b3f24 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -57,6 +57,12 @@ + + +
@@ -80,7 +86,8 @@ import { useAuthStore } from './store/auth' import { useToastStore } from './store/toast' import { useEbookPlugin } from './store/ebookPlugin' import { setLocale } from './i18n' -import SidebarContent from './components/SidebarContent.vue' +import SidebarContent from './components/SidebarContent.vue' +import ForcePasswordChangeModal from './components/ForcePasswordChangeModal.vue' const { t } = useI18n() const router = useRouter() @@ -97,6 +104,15 @@ const isAdmin = computed(() => authStore.user?.role === 'admin') const ebooksEnabled = computed(() => ebookPlugin.enabled) const toasts = computed(() => toastStore.toasts) +const mustChangePassword = computed(() => !!authStore.user?.must_change_password) + +function onPasswordChanged() { + if (authStore.user) { + authStore.user = { ...authStore.user, must_change_password: false } + localStorage.setItem('auth_user', JSON.stringify(authStore.user)) + } +} + router.afterEach(() => { sidebarOpen.value = false }) onMounted(() => { diff --git a/frontend/src/components/ForcePasswordChangeModal.vue b/frontend/src/components/ForcePasswordChangeModal.vue new file mode 100644 index 0000000..5ce813a --- /dev/null +++ b/frontend/src/components/ForcePasswordChangeModal.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/i18n/ar.js b/frontend/src/i18n/ar.js index 0a67c40..936d4ac 100644 --- a/frontend/src/i18n/ar.js +++ b/frontend/src/i18n/ar.js @@ -52,6 +52,33 @@ export default { account_created: 'تم إنشاء الحساب! يرجى تسجيل الدخول.', invalid_credentials: 'بيانات الاعتماد غير صحيحة.', error_occurred: 'حدث خطأ', + no_account_hint: 'لست عضوًا بعد؟', + forgot_password: 'نسيت كلمة المرور؟', + request_access: 'طلب الوصول', + forgot_password_title: 'إعادة تعيين كلمة المرور', + forgot_password_desc: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.', + send_reset_link: 'إرسال رابط إعادة التعيين', + reset_link_sent: 'تحقق من بريدك الوارد!', + reset_link_hint: 'تم إرسال رابط إعادة تعيين كلمة المرور. صالح لمدة 24 ساعة.', + back_to_login: 'العودة إلى تسجيل الدخول', + reset_password_title: 'تعيين كلمة مرور جديدة', + reset_password_desc: 'اختر كلمة مرور قوية لحسابك.', + set_new_password: 'تعيين كلمة المرور', + token_expired_title: 'انتهت صلاحية الرابط', + token_expired_desc: 'انتهت صلاحية رابط إعادة تعيين كلمة المرور. رجاءً طلب رابط جديد.', + request_new_link: 'طلب رابط جديد', + password_reset_success: 'تم إعادة تعيين كلمة المرور بنجاح!', + request_access_title: 'طلب الوصول', + request_access_desc: 'ليس لديك حساب؟ أرسل طلباً وسيقوم المسؤول بإنشائ حساب لك.', + request_message: 'رسالة (اختياري)', + request_message_placeholder: 'أخبرنا عن نفسك أو سبب حاجتك للوصول...', + send_request: 'إرسال الطلب', + request_sent_title: 'تم إرسال الطلب!', + request_sent_desc: 'تم إرسال طلبك إلى المسؤول. ستصلك بيانات تسجيل الدخول بعبر البريد عند الموافقة.', + force_change_title: 'قم بتعيين كلمة مرورك', + force_change_desc: 'هذا هو دخولك الأول. يرجى تعيين كلمة مرور جديدة للمتابعة.', + set_password: 'تعيين كلمة المرور', + password_set_success: 'تم تعيين كلمة المرور. أهلاً بك!', }, // Dashboard @@ -228,6 +255,21 @@ export default { user_deleted: 'تم حذف المستخدم.', delete_failed: 'فشل في حذف المستخدم.', load_failed: 'فشل في تحميل المستخدمين.', + send_credentials_email: 'إرسال بيانات الدخول بالبريد', + send_credentials_hint: 'سيتم إنشاء كلمة مرور مؤقتة وإرسالها للمستخدم.', + temp_password_hint: 'كلمة المرور المؤقتة (احفظها)', + access_requests: 'طلبات الوصول', + no_requests: 'لا توجد طلبات وصول معلقة.', + approve: 'موافقة', + reject: 'رفض', + approve_request: 'موافقة على الطلب', + approve_hint: 'إنشاء حساب لـ', + approve_email_note: 'سيتم إرسال بيانات الدخول إلى بريد المستخدم تلقائياً.', + approve_and_send: 'إنشاء وإرسال بريد', + request_approved: 'تمت الموافقة. تم إرسال بيانات الدخول.', + request_rejected: 'تم رفض الطلب.', + reject_confirm: 'رفض طلب الوصول هذا؟', + 'use_settings': 'استخدم الإعدادات', }, // Backup diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index ca4e01b..3ae80b9 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -52,6 +52,34 @@ export default { account_created: 'Account created! Please sign in.', invalid_credentials: 'Invalid credentials.', error_occurred: 'An error occurred', + // New + no_account_hint: 'Not a member yet?', + forgot_password: 'Forgot password?', + request_access: 'Request Access', + forgot_password_title: 'Reset your password', + forgot_password_desc: 'Enter your email address and we\'ll send you a link to reset your password.', + send_reset_link: 'Send Reset Link', + reset_link_sent: 'Check your inbox!', + reset_link_hint: 'A password reset link has been sent to your email. It is valid for 24 hours.', + back_to_login: 'Back to Login', + reset_password_title: 'Set New Password', + reset_password_desc: 'Choose a strong new password for your account.', + set_new_password: 'Set New Password', + token_expired_title: 'Link Expired', + token_expired_desc: 'This password reset link has expired or already been used. Please request a new one.', + request_new_link: 'Request New Link', + password_reset_success: 'Password reset successfully!', + request_access_title: 'Request Access', + request_access_desc: 'Don\'t have an account? Send a request and an admin will create one for you.', + request_message: 'Message (optional)', + request_message_placeholder: 'Tell us a bit about yourself or why you need access...', + send_request: 'Send Request', + request_sent_title: 'Request Submitted!', + request_sent_desc: 'Your request has been sent to the admin. You will receive your credentials by email once it is approved.', + force_change_title: 'Set Your Password', + force_change_desc: 'This is your first login. Please set a new password to continue using the app.', + set_password: 'Set Password', + password_set_success: 'Password set successfully. Welcome!', }, // Dashboard @@ -228,6 +256,21 @@ export default { user_deleted: 'User deleted.', delete_failed: 'Failed to delete user.', load_failed: 'Failed to load users.', + send_credentials_email: 'Send credentials by email', + send_credentials_hint: 'A temporary password will be generated and emailed to the user.', + temp_password_hint: 'Temporary password (save it)', + access_requests: 'Access Requests', + no_requests: 'No pending access requests.', + approve: 'Approve', + reject: 'Reject', + approve_request: 'Approve Request', + approve_hint: 'Create an account for', + approve_email_note: 'Credentials will be sent to the user\'s email automatically.', + approve_and_send: 'Create & Send Email', + request_approved: 'Request approved. Credentials sent.', + request_rejected: 'Request rejected.', + reject_confirm: 'Reject this access request?', + 'use_settings': 'Use Settings', }, // Backup diff --git a/frontend/src/i18n/fr.js b/frontend/src/i18n/fr.js index 4958369..b34f612 100644 --- a/frontend/src/i18n/fr.js +++ b/frontend/src/i18n/fr.js @@ -52,6 +52,33 @@ export default { account_created: 'Compte créé ! Veuillez vous connecter.', invalid_credentials: 'Identifiants invalides.', error_occurred: 'Une erreur est survenue', + no_account_hint: 'Pas encore membre ?', + forgot_password: 'Mot de passe oublié ?', + request_access: 'Demander l’accès', + forgot_password_title: 'Réinitialiser votre mot de passe', + forgot_password_desc: "Saisissez votre adresse e-mail et nous vous enverrons un lien de réinitialisation.", + send_reset_link: 'Envoyer le lien', + reset_link_sent: 'Vérifiez votre boîte de réception !', + reset_link_hint: 'Un lien de réinitialisation a été envoyé. Il est valable 24 heures.', + back_to_login: 'Retour à la connexion', + reset_password_title: 'Nouveau mot de passe', + reset_password_desc: 'Choisissez un mot de passe fort pour votre compte.', + set_new_password: 'Définir le mot de passe', + token_expired_title: 'Lien expiré', + token_expired_desc: 'Ce lien de réinitialisation a expiré ou déjà été utilisé. Veuillez en demander un nouveau.', + request_new_link: 'Demander un nouveau lien', + password_reset_success: 'Mot de passe réinitialisé avec succès !', + request_access_title: 'Demander l’accès', + request_access_desc: "Vous n'avez pas de compte ? Envoyez une demande et un admin créera votre compte.", + request_message: 'Message (optionnel)', + request_message_placeholder: 'Présentez-vous ou expliquez pourquoi vous avez besoin d’un accès...', + send_request: 'Envoyer la demande', + request_sent_title: 'Demande envoyée !', + request_sent_desc: 'Votre demande a été transmise à l’administrateur. Vous recevrez vos identifiants par e-mail après approbation.', + force_change_title: 'Définir votre mot de passe', + force_change_desc: 'C’est votre première connexion. Veuillez définir un nouveau mot de passe pour continuer.', + set_password: 'Définir le mot de passe', + password_set_success: 'Mot de passe défini avec succès. Bienvenue !', }, // Dashboard @@ -228,6 +255,21 @@ export default { user_deleted: 'Utilisateur supprimé.', delete_failed: "Échec de la suppression de l'utilisateur.", load_failed: 'Échec du chargement des utilisateurs.', + send_credentials_email: 'Envoyer les identifiants par e-mail', + send_credentials_hint: 'Un mot de passe temporaire sera généré et envoyé à l\'utilisateur.', + temp_password_hint: 'Mot de passe temporaire (à conserver)', + access_requests: 'Demandes d\'accès', + no_requests: 'Aucune demande d\'accès en attente.', + approve: 'Approuver', + reject: 'Rejeter', + approve_request: 'Approuver la demande', + approve_hint: 'Créer un compte pour', + approve_email_note: 'Les identifiants seront envoyés automatiquement par e-mail.', + approve_and_send: 'Créer et envoyer', + request_approved: 'Demande approuvée. Identifiants envoyés.', + request_rejected: 'Demande rejetée.', + reject_confirm: 'Rejeter cette demande d\'accès ?', + 'use_settings': 'Via Paramètres', }, // Backup diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8831324..8dfbbdb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -8,6 +8,24 @@ const routes = [ component: () => import('../views/LoginView.vue'), meta: { guest: true } }, + { + path: '/forgot-password', + name: 'ForgotPassword', + component: () => import('../views/ForgotPasswordView.vue'), + meta: { guest: true } + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('../views/ResetPasswordView.vue'), + meta: { guest: true } + }, + { + path: '/request-access', + name: 'RequestAccess', + component: () => import('../views/RequestAccessView.vue'), + meta: { guest: true } + }, { path: '/', name: 'Dashboard', diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index d6660c3..b5ec8b3 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -11,21 +11,18 @@ export const useAuthStore = defineStore('auth', () => { async function login(credentials) { const response = await api.post('/auth/login', credentials) token.value = response.data.token - user.value = response.data.user - localStorage.setItem('auth_token', response.data.token) - localStorage.setItem('auth_user', JSON.stringify(response.data.user)) - return response.data + user.value = response.data.user + localStorage.setItem('auth_token', response.data.token) + localStorage.setItem('auth_user', JSON.stringify(response.data.user)) + return response.data // includes must_change_password } - async function register(data) { - const response = await api.post('/auth/register', data) - return response.data - } - - async function fetchProfile() { - const response = await api.get('/auth/me') - user.value = response.data.user - localStorage.setItem('auth_user', JSON.stringify(response.data.user)) + async function refreshUser() { + try { + const response = await api.get('/auth/me') + user.value = response.data.user + localStorage.setItem('auth_user', JSON.stringify(response.data.user)) + } catch { /* silent */ } } function logout() { @@ -35,5 +32,5 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('auth_user') } - return { token, user, isAuthenticated, login, register, fetchProfile, logout } + return { token, user, isAuthenticated, login, refreshUser, logout } }) diff --git a/frontend/src/views/ForgotPasswordView.vue b/frontend/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000..373fdb5 --- /dev/null +++ b/frontend/src/views/ForgotPasswordView.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index c10e9ef..4adf62f 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,71 +1,69 @@