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.
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: <http://example.com/>
- 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 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
+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
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)
+21
View File
@@ -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'}
+52 -12
View File
@@ -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)
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')
+2 -2
View File
@@ -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()
+16 -2
View File
@@ -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()
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}
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:
+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>