diff --git a/README.md b/README.md index ec696c2..a15b9a5 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ New users created by an administrator are email-unverified and cannot sign in un The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself. -Build and start the application and local Traefik proxy: +Build and start the application: ```sh docker compose up --build @@ -199,12 +199,11 @@ docker compose up --build The services are available at: -- LinkLog through Traefik: - Direct application port: -The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`. -The application runs as a non-root user and reports container health through `/health`; Traefik waits for the application health check before starting. +The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`. +The application runs as a non-root user and reports container health through `/health`; Stop the stack without deleting its database: @@ -218,7 +217,7 @@ Stop the stack and delete the named database volume: docker compose down -v ``` -For a real deployment, replace the `localhost` router rule, configure TLS, protect the Traefik dashboard, avoid exposing the direct application port, and provide production secrets and authentication. The included Compose file is a local/prototype deployment scaffold, not a production security configuration. +For a real deployment, start with the docker-compose-example.yaml file. replace the router rule, configure TLS, protect the Traefik dashboard, avoid exposing the direct application port, and provide production secrets and authentication. The included Compose file is a local/prototype deployment scaffold, not a production security configuration. ## Mastodon Configuration diff --git a/backend/app/api/setup.py b/backend/app/api/setup.py new file mode 100644 index 0000000..58ce847 --- /dev/null +++ b/backend/app/api/setup.py @@ -0,0 +1,80 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from backend.app.core.config import settings +from backend.app.database import get_connection, hash_password +from backend.app.services.email_service import save_smtp_settings, send_test_email + +router = APIRouter() + + +class SetupRequest(BaseModel): + username: str + email: str + password: str + smtp_host: str + smtp_port: int = 587 + smtp_username: str = '' + smtp_password: str = '' + smtp_from: str + smtp_use_tls: bool = True + + +def has_administrator() -> bool: + with get_connection() as conn: + return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None + + +@router.post('', status_code=201) +def configure_application(payload: SetupRequest): + if has_administrator(): + raise HTTPException(status_code=409, detail='LinkLog is already configured') + username = payload.username.strip() + email = payload.email.strip() + smtp_host = payload.smtp_host.strip() + smtp_from = payload.smtp_from.strip() + if not username or not email or not smtp_host or not smtp_from or len(payload.password) < 8: + raise HTTPException(status_code=422, detail='Admin credentials and SMTP settings are required') + if not 1 <= payload.smtp_port <= 65535: + raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535') + + user_id = str(uuid4()) + smtp_values = { + 'smtp_host': smtp_host, + 'smtp_port': payload.smtp_port, + 'smtp_username': payload.smtp_username.strip(), + 'smtp_password': payload.smtp_password, + 'smtp_from': smtp_from, + 'smtp_use_tls': payload.smtp_use_tls, + } + with get_connection() as conn: + try: + conn.execute( + '''INSERT INTO users + (id, username, email, password_hash, is_admin, email_verified) + VALUES (?, ?, ?, ?, 1, 1)''', + (user_id, username, email, hash_password(payload.password)), + ) + except Exception as error: + raise HTTPException(status_code=409, detail='Username or email already exists') from error + conn.commit() + save_smtp_settings(smtp_values) + try: + send_test_email(email) + except Exception as error: + with get_connection() as conn: + conn.execute('DELETE FROM users WHERE id = ?', (user_id,)) + conn.execute('DELETE FROM app_settings WHERE name = ?', ('smtp',)) + conn.commit() + raise HTTPException(status_code=503, detail=f'SMTP test mail could not be sent: {error}') from error + return {'status': 'configured', 'message': 'LinkLog is configured and the SMTP test mail was sent.'} + + +@router.get('/status') +def setup_status(): + return {'configured': has_administrator()} \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py index a228285..afe0dab 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -116,6 +116,13 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens ( ); CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); +'''), + (6, ''' +CREATE TABLE IF NOT EXISTS app_settings ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); '''), ] @@ -152,23 +159,6 @@ def seed_default_tags(conn: sqlite3.Connection) -> None: def init_db() -> None: with get_connection() as conn: apply_migrations(conn) - alice_hash = hash_password('secret123') - bob_hash = hash_password('secret123') - conn.execute( - ''' - INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin) - VALUES (?, ?, ?, ?, 1) - ''', - ('user-1', 'alice', 'alice@example.com', alice_hash) - ) - conn.execute("UPDATE users SET is_admin = 1 WHERE id = 'user-1'") - conn.execute( - ''' - INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin) - VALUES (?, ?, ?, ?, 0) - ''', - ('user-2', 'bob', 'bob@example.com', bob_hash) - ) conn.execute( ''' INSERT OR IGNORE INTO plugins (id, name, version, enabled, config) diff --git a/backend/app/main.py b/backend/app/main.py index 2ded283..2bc1fc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.requests import Request @@ -11,6 +12,8 @@ from backend.app.api.admin import router as admin_router from backend.app.api.auth import router as auth_router from backend.app.api.links import router as links_router from backend.app.api.public import router as public_router +from backend.app.api.setup import router as setup_router +from backend.app.api.setup import has_administrator from backend.app.api.user_config import router as user_config_router from backend.app.core.config import settings from backend.app.database import AVATARS_DIR @@ -24,28 +27,37 @@ app.include_router(links_router, prefix='/api') app.include_router(public_router, prefix='/api/public') app.include_router(admin_router, prefix='/api/admin') app.include_router(user_config_router, prefix='/api/user') +app.include_router(setup_router, prefix='/api/setup') templates = Jinja2Templates(directory='frontend/templates') @app.get('/', response_class=HTMLResponse) async def public_root(request: Request): + if not has_administrator(): + return RedirectResponse('/setup') feed = list_public_links() return templates.TemplateResponse(request, 'feed.html', {'feed': feed}) @app.get('/admin', response_class=HTMLResponse) async def admin_dashboard(request: Request): + if not has_administrator(): + return RedirectResponse('/setup') return templates.TemplateResponse(request, 'admin.html', {}) @app.get('/profile', response_class=HTMLResponse) async def user_profile_page(request: Request): + if not has_administrator(): + return RedirectResponse('/setup') return templates.TemplateResponse(request, 'user_profile.html', {}) @app.get('/labels', response_class=HTMLResponse) async def labels_page(request: Request): + if not has_administrator(): + return RedirectResponse('/setup') return templates.TemplateResponse(request, 'labels.html', {}) @@ -56,9 +68,18 @@ async def about_page(request: Request): @app.get('/login', response_class=HTMLResponse) async def login_page(request: Request): + if not has_administrator(): + return RedirectResponse('/setup') return templates.TemplateResponse(request, 'login.html', {}) +@app.get('/setup', response_class=HTMLResponse) +async def setup_page(request: Request): + if has_administrator(): + return RedirectResponse('/') + return templates.TemplateResponse(request, 'setup.html', {}) + + @app.get('/health') def health_check(): return {'status': 'ok'} diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 86ca9cc..abd112f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -3,31 +3,71 @@ from email.message import EmailMessage from smtplib import SMTP +import json from backend.app.core.config import settings +from backend.app.database import get_connection + + +def get_smtp_settings() -> dict: + values = { + 'smtp_host': settings.smtp_host, + 'smtp_port': settings.smtp_port, + 'smtp_username': settings.smtp_username, + 'smtp_password': settings.smtp_password, + 'smtp_from': settings.smtp_from, + 'smtp_use_tls': settings.smtp_use_tls, + } + with get_connection() as conn: + row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone() + if row: + values.update(json.loads(row['value'])) + return values + + +def save_smtp_settings(values: dict) -> None: + with get_connection() as conn: + conn.execute( + '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', + ('smtp', json.dumps(values)), + ) + conn.commit() def smtp_configured() -> bool: - return bool(settings.smtp_host and settings.smtp_from) + smtp = get_smtp_settings() + return bool(smtp['smtp_host'] and smtp['smtp_from']) -def send_verification_email(email: str, username: str, verification_url: str) -> None: +def send_message(email: str, subject: str, body: str) -> None: + smtp = get_smtp_settings() if not smtp_configured(): raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') message = EmailMessage() - message['Subject'] = 'Verify your LinkLog email address' - message['From'] = settings.smtp_from + message['Subject'] = subject + message['From'] = smtp['smtp_from'] message['To'] = email - message.set_content( + message.set_content(body) + + with SMTP(smtp['smtp_host'], smtp['smtp_port'], timeout=10) as connection: + if smtp['smtp_use_tls']: + connection.starttls() + if smtp['smtp_username']: + connection.login(smtp['smtp_username'], smtp['smtp_password']) + connection.send_message(message) + + +def send_verification_email(email: str, username: str, verification_url: str) -> None: + send_message( + email, + 'Verify your LinkLog email address', f'Hello {username},\n\n' f'Verify your LinkLog email address by opening this link:\n{verification_url}\n\n' - f'This link expires in {settings.email_verification_expiry_hours} hours.\n' + f'This link expires in {settings.email_verification_expiry_hours} hours.\n', ) - with SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: - if settings.smtp_use_tls: - smtp.starttls() - if settings.smtp_username: - smtp.login(settings.smtp_username, settings.smtp_password) - smtp.send_message(message) \ No newline at end of file + +def send_test_email(email: str) -> None: + send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n') \ No newline at end of file diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index b451efa..2ecac70 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent(): connection = sqlite3.connect(':memory:') apply_migrations(connection) - assert get_schema_version(connection) == 5 + assert get_schema_version(connection) == 6 tables = { row[0] for row in connection.execute( @@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent(): assert set(DEFAULT_TAGS) <= seeded_tags apply_migrations(connection) - assert get_schema_version(connection) == 5 + assert get_schema_version(connection) == 6 connection.close() \ No newline at end of file diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py index 5ec8aed..71d7431 100644 --- a/backend/tests/test_email_service.py +++ b/backend/tests/test_email_service.py @@ -3,7 +3,7 @@ from unittest.mock import patch -from backend.app.services.email_service import send_verification_email +from backend.app.services.email_service import send_test_email, send_verification_email def test_send_verification_email_uses_smtp_settings(monkeypatch): @@ -25,4 +25,18 @@ def test_send_verification_email_uses_smtp_settings(monkeypatch): smtp.login.assert_called_once_with('mailer', 'secret') message = smtp.send_message.call_args.args[0] assert message['To'] == 'user@example.com' - assert 'https://linklog.example/verify' in message.get_content() \ No newline at end of file + assert 'https://linklog.example/verify' in message.get_content() + + +def test_send_test_email_uses_configured_recipient(monkeypatch): + from backend.app.core.config import settings + + monkeypatch.setattr(settings, 'smtp_host', 'smtp.example.com') + monkeypatch.setattr(settings, 'smtp_from', 'LinkLog ') + with patch('backend.app.services.email_service.SMTP') as smtp_class: + smtp = smtp_class.return_value.__enter__.return_value + send_test_email('admin@example.com') + + message = smtp.send_message.call_args.args[0] + assert message['To'] == 'admin@example.com' + assert message['Subject'] == 'LinkLog SMTP test' \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1ea808c..f0affe6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,12 +33,7 @@ services: timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s} start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s} retries: ${APP_HEALTHCHECK_RETRIES:-3} - labels: - - "traefik.enable=true" - - "traefik.http.routers.linklog.rule=Host(`${TRAEFIK_HOST:-localhost}`)" - - "traefik.http.routers.linklog.entrypoints=web" - - "traefik.http.services.linklog.loadbalancer.server.port=8000" - + volumes: linklog_data: diff --git a/frontend/static/setup.js b/frontend/static/setup.js new file mode 100644 index 0000000..067b314 --- /dev/null +++ b/frontend/static/setup.js @@ -0,0 +1,26 @@ +// Copyright © 2026 Olaf Kolkman +// SPDX-License-Identifier: GPL-3.0-or-later + +document.querySelector('#setup-form').addEventListener('submit', async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const status = document.querySelector('#setup-status'); + const values = Object.fromEntries(new FormData(form)); + values.smtp_port = Number(values.smtp_port); + values.smtp_use_tls = form.elements.smtp_use_tls.checked; + try { + const response = await fetch('/api/setup', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(values), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || 'Setup failed'); + status.textContent = result.message; + status.style.color = '#94e2d5'; + form.replaceChildren(status); + } catch (error) { + status.textContent = error.message; + status.style.color = '#f38ba8'; + } +}); \ No newline at end of file diff --git a/frontend/templates/setup.html b/frontend/templates/setup.html new file mode 100644 index 0000000..ef3536e --- /dev/null +++ b/frontend/templates/setup.html @@ -0,0 +1,45 @@ + + + + + + + Configure LinkLog + + + + + +
+ +
+
Copyright © 2026 Olaf Kolkman
+ + + \ No newline at end of file