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
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file