Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions .env.ssl.example
Original file line number Diff line number Diff line change
@@ -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
#
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
214 changes: 214 additions & 0 deletions backend/app/Controllers/AccessRequestController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

namespace App\Controllers;

use App\Config\Database;
use App\Services\MailService;

/**
* Access Request Controller
* Non-members can request an account; admin reviews and approves.
*/
class AccessRequestController extends BaseController
{
// =========================================================
// POST /api/auth/request-access (public)
// Body: { "email": "...", "message": "..." }
// =========================================================
public function store(array $params): void
{
$data = $this->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);
}
}
Loading
Loading