first setup configuration
Build LinkLog Development Image / development-image (push) Successful in 10s

This commit is contained in:
Olaf
2026-08-25 22:51:17 +02:00
parent 07e520e03f
commit defe7a83a9
10 changed files with 254 additions and 44 deletions
+4 -5
View File
@@ -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. 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 ```sh
docker compose up --build docker compose up --build
@@ -199,12 +199,11 @@ docker compose up --build
The services are available at: The services are available at:
- LinkLog through Traefik: <http://example.com/>
- Direct application port: <http://localhost:8000/> - Direct application port: <http://localhost:8000/>
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 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 application runs as a non-root user and reports container health through `/health`;
Stop the stack without deleting its database: Stop the stack without deleting its database:
@@ -218,7 +217,7 @@ Stop the stack and delete the named database volume:
docker compose down -v 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 ## Mastodon Configuration
+80
View File
@@ -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()}
+7 -17
View File
@@ -116,6 +116,13 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens (
); );
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id
ON 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: def init_db() -> None:
with get_connection() as conn: with get_connection() as conn:
apply_migrations(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( conn.execute(
''' '''
INSERT OR IGNORE INTO plugins (id, name, version, enabled, config) INSERT OR IGNORE INTO plugins (id, name, version, enabled, config)
+21
View File
@@ -3,6 +3,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.requests import Request 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.auth import router as auth_router
from backend.app.api.links import router as links_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.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.api.user_config import router as user_config_router
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import AVATARS_DIR 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(public_router, prefix='/api/public')
app.include_router(admin_router, prefix='/api/admin') app.include_router(admin_router, prefix='/api/admin')
app.include_router(user_config_router, prefix='/api/user') app.include_router(user_config_router, prefix='/api/user')
app.include_router(setup_router, prefix='/api/setup')
templates = Jinja2Templates(directory='frontend/templates') templates = Jinja2Templates(directory='frontend/templates')
@app.get('/', response_class=HTMLResponse) @app.get('/', response_class=HTMLResponse)
async def public_root(request: Request): async def public_root(request: Request):
if not has_administrator():
return RedirectResponse('/setup')
feed = list_public_links() feed = list_public_links()
return templates.TemplateResponse(request, 'feed.html', {'feed': feed}) return templates.TemplateResponse(request, 'feed.html', {'feed': feed})
@app.get('/admin', response_class=HTMLResponse) @app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request): async def admin_dashboard(request: Request):
if not has_administrator():
return RedirectResponse('/setup')
return templates.TemplateResponse(request, 'admin.html', {}) return templates.TemplateResponse(request, 'admin.html', {})
@app.get('/profile', response_class=HTMLResponse) @app.get('/profile', response_class=HTMLResponse)
async def user_profile_page(request: Request): async def user_profile_page(request: Request):
if not has_administrator():
return RedirectResponse('/setup')
return templates.TemplateResponse(request, 'user_profile.html', {}) return templates.TemplateResponse(request, 'user_profile.html', {})
@app.get('/labels', response_class=HTMLResponse) @app.get('/labels', response_class=HTMLResponse)
async def labels_page(request: Request): async def labels_page(request: Request):
if not has_administrator():
return RedirectResponse('/setup')
return templates.TemplateResponse(request, 'labels.html', {}) return templates.TemplateResponse(request, 'labels.html', {})
@@ -56,9 +68,18 @@ async def about_page(request: Request):
@app.get('/login', response_class=HTMLResponse) @app.get('/login', response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
if not has_administrator():
return RedirectResponse('/setup')
return templates.TemplateResponse(request, 'login.html', {}) 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') @app.get('/health')
def health_check(): def health_check():
return {'status': 'ok'} return {'status': 'ok'}
+52 -12
View File
@@ -3,31 +3,71 @@
from email.message import EmailMessage from email.message import EmailMessage
from smtplib import SMTP from smtplib import SMTP
import json
from backend.app.core.config import settings 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: 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(): if not smtp_configured():
raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM')
message = EmailMessage() message = EmailMessage()
message['Subject'] = 'Verify your LinkLog email address' message['Subject'] = subject
message['From'] = settings.smtp_from message['From'] = smtp['smtp_from']
message['To'] = email 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'Hello {username},\n\n'
f'Verify your LinkLog email address by opening this link:\n{verification_url}\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: def send_test_email(email: str) -> None:
smtp.starttls() send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n')
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
+2 -2
View File
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:') connection = sqlite3.connect(':memory:')
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 5 assert get_schema_version(connection) == 6
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 5 assert get_schema_version(connection) == 6
connection.close() connection.close()
+16 -2
View File
@@ -3,7 +3,7 @@
from unittest.mock import patch 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): 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') smtp.login.assert_called_once_with('mailer', 'secret')
message = smtp.send_message.call_args.args[0] message = smtp.send_message.call_args.args[0]
assert message['To'] == 'user@example.com' assert message['To'] == 'user@example.com'
assert 'https://linklog.example/verify' in message.get_content() 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 <no-reply@example.com>')
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'
+1 -6
View File
@@ -33,12 +33,7 @@ services:
timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s} timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s}
start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s} start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s}
retries: ${APP_HEALTHCHECK_RETRIES:-3} 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: volumes:
linklog_data: linklog_data:
+26
View File
@@ -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';
}
});
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- Copyright © 2026 Olaf Kolkman -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Configure LinkLog</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
<h1>Configure LinkLog</h1>
<p>Create the first administrator and test email delivery.</p>
</div>
</div>
</div>
</header>
<main class="container">
<section class="link-item settings-panel">
<form id="setup-form">
<h2>Administrator</h2>
<label>Username<input name="username" type="text" autocomplete="username" required /></label>
<label>Email address<input name="email" type="email" autocomplete="email" required /></label>
<label>Password<input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
<h2>SMTP</h2>
<label>SMTP host<input name="smtp_host" type="text" required /></label>
<label>SMTP port<input name="smtp_port" type="number" min="1" max="65535" value="587" required /></label>
<label>SMTP username<input name="smtp_username" type="text" autocomplete="off" /></label>
<label>SMTP password<input name="smtp_password" type="password" autocomplete="new-password" /></label>
<label>From address<input name="smtp_from" type="text" value="LinkLog &lt;no-reply@example.com&gt;" required /></label>
<label class="checkbox-label"><input name="smtp_use_tls" type="checkbox" checked /> Use STARTTLS</label>
<button type="submit">Save and send test mail</button>
<p id="setup-status" class="status" role="status"></p>
</form>
</section>
</main>
<footer class="site-footer">Copyright © 2026 Olaf Kolkman</footer>
<script src="/static/setup.js"></script>
</body>
</html>