Initial config with email service test
Build LinkLog Development Image / development-image (push) Successful in 11s

This commit is contained in:
Olaf
2026-08-25 23:23:28 +02:00
parent defe7a83a9
commit 102d8e533c
20 changed files with 482 additions and 34 deletions
+1
View File
@@ -19,6 +19,7 @@ LINKLOG_SMTP_PASSWORD=
LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost> LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
LINKLOG_SMTP_USE_TLS=true LINKLOG_SMTP_USE_TLS=true
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24 LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
# Optional comma-separated override. Leave empty to use the built-in list. # Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS= LINKLOG_TRACKING_PARAMS=
# Keep the default path when using the named linklog_data volume. # Keep the default path when using the named linklog_data volume.
+5
View File
@@ -183,12 +183,17 @@ The main configurable values are:
| `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog <no-reply@localhost>` | | `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog <no-reply@localhost>` |
| `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` | | `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` |
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` | | `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
| `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` | Password-reset-link lifetime | `1` |
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list | | `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` | | `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
| `APP_PORT` | direct host port for FastAPI | `8000` | | `APP_PORT` | direct host port for FastAPI | `8000` |
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent. New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
On a fresh installation, the setup form is prefilled from the `LINKLOG_SMTP_*` environment values when available. After saving, the values stored in the database are used for subsequent setup-page loads and mail delivery.
When a verified user enters the wrong password, LinkLog keeps the response generic and sends a password-reset link to that account's email address when SMTP is configured. Reset links expire after `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` hours, can be used once, and revoke existing sessions after the password is changed.
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: Build and start the application:
+26 -1
View File
@@ -7,8 +7,11 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from backend.app.database import get_connection, init_db from backend.app.database import get_connection, init_db
from backend.app.services.auth_service import authenticate_user from backend.app.core.config import settings
from backend.app.services.auth_service import authenticate_user, find_user
from backend.app.services.email_service import send_password_reset_email, smtp_configured
from backend.app.services.email_verification import verify_email from backend.app.services.email_verification import verify_email
from backend.app.services.password_reset import create_reset_token, reset_password
from backend.app.services.token_service import issue_token, revoke_token, validate_token from backend.app.services.token_service import issue_token, revoke_token, validate_token
router = APIRouter() router = APIRouter()
@@ -21,10 +24,23 @@ class LoginRequest(BaseModel):
password: str password: str
class PasswordResetRequest(BaseModel):
token: str
password: str
@router.post('/login') @router.post('/login')
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)
if user is None: if user is None:
reset_user = find_user(payload.username)
if reset_user and reset_user['email_verified'] and smtp_configured():
try:
token = create_reset_token(reset_user['id'])
reset_url = f'{settings.public_url}/reset-password?token={token}'
send_password_reset_email(reset_user['email'], reset_user['username'], reset_url)
except Exception:
pass
raise HTTPException(status_code=401, detail='Invalid username or password') raise HTTPException(status_code=401, detail='Invalid username or password')
if not user['email_verified']: if not user['email_verified']:
raise HTTPException(status_code=403, detail='Email address is not verified') raise HTTPException(status_code=403, detail='Email address is not verified')
@@ -46,6 +62,15 @@ def verify_email_address(token: str):
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'} return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
@router.post('/reset-password')
def reset_password_endpoint(payload: PasswordResetRequest):
if len(payload.password) < 8:
raise HTTPException(status_code=422, detail='Password must contain at least 8 characters')
if not reset_password(payload.token, payload.password):
raise HTTPException(status_code=400, detail='Password reset link is invalid or expired')
return {'status': 'password_reset', 'message': 'Password reset. You can now sign in.'}
@router.post('/logout') @router.post('/logout')
def logout(payload: dict): def logout(payload: dict):
token = payload.get('token') token = payload.get('token')
+128 -17
View File
@@ -1,6 +1,8 @@
## Copyright © 2026 Olaf Kolkman ## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timedelta, timezone
import json
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -8,7 +10,7 @@ from pydantic import BaseModel
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import get_connection, hash_password from backend.app.database import get_connection, hash_password
from backend.app.services.email_service import save_smtp_settings, send_test_email from backend.app.services.email_service import get_smtp_settings, save_smtp_settings, send_test_email
router = APIRouter() router = APIRouter()
@@ -25,13 +27,41 @@ class SetupRequest(BaseModel):
smtp_use_tls: bool = True smtp_use_tls: bool = True
class TestMailRequest(BaseModel):
email: str | None = None
def has_administrator() -> bool: def has_administrator() -> bool:
with get_connection() as conn: with get_connection() as conn:
return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None
@router.post('', status_code=201) def get_pending_setup() -> dict | None:
def configure_application(payload: SetupRequest): with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_pending',)).fetchone()
return json.loads(row['value']) if row else None
def save_pending_setup(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''',
('setup_pending', json.dumps(values)),
)
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',))
conn.commit()
def delete_pending_setup() -> None:
with get_connection() as conn:
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_pending',))
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',))
conn.commit()
@router.post('/configuration', status_code=200)
def save_configuration(payload: SetupRequest):
if has_administrator(): if has_administrator():
raise HTTPException(status_code=409, detail='LinkLog is already configured') raise HTTPException(status_code=409, detail='LinkLog is already configured')
username = payload.username.strip() username = payload.username.strip()
@@ -43,7 +73,6 @@ def configure_application(payload: SetupRequest):
if not 1 <= payload.smtp_port <= 65535: if not 1 <= payload.smtp_port <= 65535:
raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535') raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535')
user_id = str(uuid4())
smtp_values = { smtp_values = {
'smtp_host': smtp_host, 'smtp_host': smtp_host,
'smtp_port': payload.smtp_port, 'smtp_port': payload.smtp_port,
@@ -52,29 +81,111 @@ def configure_application(payload: SetupRequest):
'smtp_from': smtp_from, 'smtp_from': smtp_from,
'smtp_use_tls': payload.smtp_use_tls, 'smtp_use_tls': payload.smtp_use_tls,
} }
pending = {
'username': username,
'email': email,
'password_hash': hash_password(payload.password),
}
save_pending_setup(pending)
save_smtp_settings(smtp_values)
return {'status': 'saved', 'message': 'Configuration saved. Send a test mail to verify SMTP delivery.'}
@router.post('/test-mail')
def test_mail(payload: TestMailRequest | None = None):
if has_administrator():
raise HTTPException(status_code=409, detail='LinkLog is already configured')
pending = get_pending_setup()
if pending is None:
raise HTTPException(status_code=400, detail='Save the configuration before sending test mail')
now = datetime.now(timezone.utc)
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
rate = json.loads(row['value']) if row else {}
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
if cooldown_until and now >= cooldown_until:
rate = {}
last_sent = None
cooldown_until = None
if cooldown_until and now < cooldown_until:
retry_after = int((cooldown_until - now).total_seconds()) + 1
raise HTTPException(status_code=429, detail=f'Test mail limit reached. Try again in {retry_after} seconds.', headers={'Retry-After': str(retry_after)})
if last_sent and now - last_sent < timedelta(seconds=20):
retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before sending another test mail.', headers={'Retry-After': str(retry_after)})
try:
send_test_email(payload.email if payload and payload.email else pending['email'])
except Exception as error:
raise HTTPException(status_code=503, detail=f'SMTP test mail could not be sent: {error}') from error
sends = int(rate.get('sends', 0)) + 1
updated_rate = {'sends': sends, 'last_sent': now.isoformat()}
if sends >= 5:
updated_rate['cooldown_until'] = (now + timedelta(minutes=2)).isoformat()
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''',
('setup_mail_rate', json.dumps(updated_rate)),
)
conn.commit()
next_allowed = datetime.fromisoformat(updated_rate.get('cooldown_until')) if sends >= 5 else now + timedelta(seconds=20)
return {
'status': 'sent',
'message': 'SMTP test mail sent.',
'sends_remaining': max(0, 5 - sends),
'cooldown_seconds': 120 if sends >= 5 else 0,
'next_allowed_at': next_allowed.isoformat(),
}
@router.post('/complete', status_code=201)
def complete_setup():
if has_administrator():
raise HTTPException(status_code=409, detail='LinkLog is already configured')
pending = get_pending_setup()
if pending is None:
raise HTTPException(status_code=400, detail='Save the configuration before completing setup')
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
if row is None or int(json.loads(row['value']).get('sends', 0)) < 1:
raise HTTPException(status_code=400, detail='Send a successful test mail before completing setup')
user_id = str(uuid4())
with get_connection() as conn: with get_connection() as conn:
try: try:
conn.execute( conn.execute(
'''INSERT INTO users '''INSERT INTO users
(id, username, email, password_hash, is_admin, email_verified) (id, username, email, password_hash, is_admin, email_verified)
VALUES (?, ?, ?, ?, 1, 1)''', VALUES (?, ?, ?, ?, 1, 1)''',
(user_id, username, email, hash_password(payload.password)), (user_id, pending['username'], pending['email'], pending['password_hash']),
) )
conn.commit()
except Exception as error: except Exception as error:
raise HTTPException(status_code=409, detail='Username or email already exists') from error raise HTTPException(status_code=409, detail='Username or email already exists') from error
conn.commit() delete_pending_setup()
save_smtp_settings(smtp_values) return {'status': 'configured', 'message': 'LinkLog is configured.'}
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') @router.get('/status')
def setup_status(): def setup_status():
return {'configured': has_administrator()} rate = {}
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
if row:
rate = json.loads(row['value'])
now = datetime.now(timezone.utc)
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
next_allowed = cooldown_until if cooldown_until and cooldown_until > now else (
last_sent + timedelta(seconds=20) if last_sent else None
)
return {
'configured': has_administrator(),
'pending': get_pending_setup() is not None,
'smtp_defaults': get_smtp_settings(),
'sends_remaining': max(0, 5 - int(rate.get('sends', 0))),
'next_allowed_at': next_allowed.isoformat() if next_allowed and next_allowed > now else None,
}
+1
View File
@@ -25,6 +25,7 @@ class Settings:
smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>') smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>')
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'} smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24')) email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
tracking_params: list[str] = None tracking_params: list[str] = None
def __post_init__(self): def __post_init__(self):
+12
View File
@@ -123,6 +123,18 @@ CREATE TABLE IF NOT EXISTS app_settings (
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
'''),
(7, '''
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id
ON password_reset_tokens(user_id);
'''), '''),
] ]
+5
View File
@@ -73,6 +73,11 @@ async def login_page(request: Request):
return templates.TemplateResponse(request, 'login.html', {}) return templates.TemplateResponse(request, 'login.html', {})
@app.get('/reset-password', response_class=HTMLResponse)
async def reset_password_page(request: Request):
return templates.TemplateResponse(request, 'reset-password.html', {})
@app.get('/setup', response_class=HTMLResponse) @app.get('/setup', response_class=HTMLResponse)
async def setup_page(request: Request): async def setup_page(request: Request):
if has_administrator(): if has_administrator():
+6
View File
@@ -19,3 +19,9 @@ def authenticate_user(username: str, password: str):
(username, password_hash), (username, password_hash),
).fetchone() ).fetchone()
return dict(row) if row else None return dict(row) if row else None
def find_user(username: str):
with get_connection() as conn:
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
return dict(row) if row else None
+10
View File
@@ -71,3 +71,13 @@ def send_verification_email(email: str, username: str, verification_url: str) ->
def send_test_email(email: str) -> None: 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') send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n')
def send_password_reset_email(email: str, username: str, reset_url: str) -> None:
send_message(
email,
'Reset your LinkLog password',
f'Hello {username},\n\n'
f'Reset your LinkLog password by opening this link:\n{reset_url}\n\n'
f'This link expires in {settings.password_reset_expiry_hours} hours.\n',
)
+48
View File
@@ -0,0 +1,48 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from secrets import token_urlsafe
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.database import get_connection, hash_password
def hash_reset_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest()
def create_reset_token(user_id: str) -> str:
token = token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
with get_connection() as conn:
conn.execute('DELETE FROM password_reset_tokens WHERE user_id = ?', (user_id,))
conn.execute(
'''INSERT INTO password_reset_tokens
(id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)''',
(str(uuid4()), user_id, hash_reset_token(token), expires_at.isoformat()),
)
conn.commit()
return token
def reset_password(token: str, password: str) -> bool:
now = datetime.now(timezone.utc).isoformat()
with get_connection() as conn:
row = conn.execute(
'''SELECT user_id FROM password_reset_tokens
WHERE token_hash = ? AND expires_at > ?''',
(hash_reset_token(token), now),
).fetchone()
if row is None:
return False
conn.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(hash_password(password), row['user_id']),
)
conn.execute('DELETE FROM password_reset_tokens WHERE user_id = ?', (row['user_id'],))
conn.execute('DELETE FROM tokens WHERE user_id = ?', (row['user_id'],))
conn.commit()
return True
+32
View File
@@ -0,0 +1,32 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
import os
import tempfile
import pytest
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
@pytest.fixture(scope='session', autouse=True)
def test_users():
from backend.app.database import get_connection, hash_password, init_db
init_db()
with get_connection() as conn:
conn.executemany(
'''INSERT INTO users
(id, username, email, password_hash, is_admin, email_verified)
VALUES (?, ?, ?, ?, ?, 1)''',
[
('user-1', 'alice', 'alice@example.com', hash_password('secret123'), 1),
('user-2', 'bob', 'bob@example.com', hash_password('secret123'), 0),
],
)
conn.commit()
yield
TEST_DATABASE_DIRECTORY.cleanup()
+46
View File
@@ -5,10 +5,13 @@ import json
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from uuid import uuid4 from uuid import uuid4
from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.app.main import app from backend.app.main import app
from backend.app.database import get_connection
from backend.app.services.password_reset import create_reset_token
from backend.app.services.token_service import issue_token from backend.app.services.token_service import issue_token
@@ -110,6 +113,49 @@ def test_email_verification_link_enables_login():
assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400 assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400
def test_mistyped_password_sends_reset_link_without_changing_login_error():
username = f'mistyped-{uuid4().hex}'
admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"}
created = client.post('/api/admin/users', headers=admin_headers, json={
'username': username,
'email': f'{username}@example.com',
'password': 'secret123',
})
user_id = created.json()['id']
with get_connection() as conn:
conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,))
conn.commit()
with patch('backend.app.api.auth.smtp_configured', return_value=True), \
patch('backend.app.api.auth.create_reset_token', return_value='reset-token') as create_token, \
patch('backend.app.api.auth.send_password_reset_email') as send_email:
response = client.post('/api/auth/login', json={'username': username, 'password': 'wrong-password'})
assert response.status_code == 401
assert response.json()['detail'] == 'Invalid username or password'
create_token.assert_called_once_with(user_id)
send_email.assert_called_once()
assert send_email.call_args.args[2].endswith('/reset-password?token=reset-token')
def test_password_reset_is_single_use_and_revokes_sessions():
username = f'reset-owner-{uuid4().hex}'
admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"}
created = client.post('/api/admin/users', headers=admin_headers, json={
'username': username,
'email': f'{username}@example.com',
'password': 'secret123',
})
user_id = created.json()['id']
with get_connection() as conn:
conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,))
conn.commit()
token = create_reset_token(user_id)
reset = client.post('/api/auth/reset-password', json={'token': token, 'password': 'new-secret123'})
assert reset.status_code == 200
assert client.post('/api/auth/reset-password', json={'token': token, 'password': 'another-secret'}).status_code == 400
assert client.post('/api/auth/login', json={'username': username, 'password': 'new-secret123'}).status_code == 200
def test_admin_can_remove_user_with_owned_data(): def test_admin_can_remove_user_with_owned_data():
headers = login_headers() headers = login_headers()
username = f'data-owner-{uuid4().hex}' username = f'data-owner-{uuid4().hex}'
+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) == 6 assert get_schema_version(connection) == 7
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) == 6 assert get_schema_version(connection) == 7
connection.close() connection.close()
+1
View File
@@ -22,6 +22,7 @@ services:
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@example.com>} LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@example.com>}
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped} restart: ${APP_RESTART_POLICY:-unless-stopped}
healthcheck: healthcheck:
+1
View File
@@ -25,6 +25,7 @@ services:
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>} LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>}
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped} restart: ${APP_RESTART_POLICY:-unless-stopped}
healthcheck: healthcheck:
+19
View File
@@ -0,0 +1,19 @@
// Copyright © 2026 Olaf Kolkman
// SPDX-License-Identifier: GPL-3.0-or-later
const resetForm = document.querySelector('#reset-password-form');
const resetStatus = document.querySelector('#reset-password-status');
const resetToken = new URLSearchParams(window.location.search).get('token');
resetForm.addEventListener('submit', async (event) => {
event.preventDefault();
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: resetToken, password: new FormData(resetForm).get('password')}),
});
const result = await response.json();
resetStatus.textContent = response.ok ? `${result.message} Redirecting...` : (result.detail || 'Reset failed.');
resetStatus.style.color = response.ok ? '#166534' : '#b91c1c';
if (response.ok) window.setTimeout(() => window.location.assign('/login'), 1000);
});
+105 -8
View File
@@ -1,26 +1,123 @@
// Copyright © 2026 Olaf Kolkman // Copyright © 2026 Olaf Kolkman
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
document.querySelector('#setup-form').addEventListener('submit', async (event) => { const setupForm = document.querySelector('#setup-form');
const testMailButton = document.querySelector('#test-mail-button');
const completeSetupButton = document.querySelector('#complete-setup-button');
const setupStatus = document.querySelector('#setup-status');
const setupTimer = document.querySelector('#setup-timer');
let nextAllowedAt = null;
let timerHandle = null;
function updateTimer() {
if (timerHandle) window.clearTimeout(timerHandle);
if (!nextAllowedAt) {
setupTimer.textContent = '';
testMailButton.disabled = !setupForm.dataset.saved;
return;
}
const seconds = Math.max(0, Math.ceil((nextAllowedAt - Date.now()) / 1000));
if (seconds === 0) {
nextAllowedAt = null;
updateTimer();
return;
}
const minutes = Math.floor(seconds / 60);
setupTimer.textContent = `Next test mail available in ${minutes ? `${minutes}m ` : ''}${seconds % 60}s.`;
testMailButton.disabled = true;
timerHandle = window.setTimeout(updateTimer, 1000);
}
function applyStatus(result) {
setupForm.dataset.saved = result.pending ? 'true' : '';
testMailButton.disabled = !result.pending;
const smtpDefaults = result.smtp_defaults || {};
for (const [name, value] of Object.entries(smtpDefaults)) {
const field = setupForm.elements[name];
if (!field || field.value || field.type === 'checkbox') continue;
field.value = value;
}
if (typeof smtpDefaults.smtp_use_tls === 'boolean') {
setupForm.elements.smtp_use_tls.checked = smtpDefaults.smtp_use_tls;
}
if (result.next_allowed_at) nextAllowedAt = Date.parse(result.next_allowed_at);
updateTimer();
}
async function loadSetupStatus() {
const response = await fetch('/api/setup/status');
if (!response.ok) return;
applyStatus(await response.json());
}
setupForm.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget; const form = event.currentTarget;
const status = document.querySelector('#setup-status');
const values = Object.fromEntries(new FormData(form)); const values = Object.fromEntries(new FormData(form));
values.smtp_port = Number(values.smtp_port); values.smtp_port = Number(values.smtp_port);
values.smtp_use_tls = form.elements.smtp_use_tls.checked; values.smtp_use_tls = form.elements.smtp_use_tls.checked;
try { try {
const response = await fetch('/api/setup', { const response = await fetch('/api/setup/configuration', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(values), body: JSON.stringify(values),
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Setup failed'); if (!response.ok) throw new Error(result.detail || 'Setup failed');
status.textContent = result.message; setupStatus.textContent = result.message;
status.style.color = '#94e2d5'; setupStatus.style.color = '#94e2d5';
form.replaceChildren(status); form.dataset.saved = 'true';
testMailButton.disabled = false;
} catch (error) { } catch (error) {
status.textContent = error.message; setupStatus.textContent = error.message;
status.style.color = '#f38ba8'; setupStatus.style.color = '#f38ba8';
} }
}); });
testMailButton.addEventListener('click', async () => {
testMailButton.disabled = true;
try {
const response = await fetch('/api/setup/test-mail', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: setupForm.elements.email.value}),
});
const result = await response.json();
if (!response.ok) {
if (response.status === 429 && response.headers.get('Retry-After')) {
nextAllowedAt = Date.now() + Number(response.headers.get('Retry-After')) * 1000;
updateTimer();
}
throw new Error(result.detail || 'Test mail failed');
}
setupStatus.textContent = `${result.message} ${result.sends_remaining} sends remaining.`;
setupStatus.style.color = '#94e2d5';
if (result.next_allowed_at) nextAllowedAt = Date.parse(result.next_allowed_at);
updateTimer();
completeSetupButton.hidden = false;
} catch (error) {
setupStatus.textContent = error.message;
setupStatus.style.color = '#f38ba8';
if (!nextAllowedAt) testMailButton.disabled = false;
}
});
completeSetupButton.addEventListener('click', async () => {
const response = await fetch('/api/setup/complete', {method: 'POST'});
const result = await response.json();
if (!response.ok) {
setupStatus.textContent = result.detail || 'Could not complete setup.';
setupStatus.style.color = '#f38ba8';
return;
}
setupStatus.style.color = '#94e2d5';
setupStatus.textContent = '';
setupStatus.append(document.createTextNode(`${result.message} `));
const homeLink = document.createElement('a');
homeLink.href = '/';
homeLink.textContent = 'Home';
setupStatus.append(homeLink);
setupForm.replaceChildren(setupStatus);
});
loadSetupStatus();
+1
View File
@@ -47,6 +47,7 @@
</label> </label>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
<p id="login-status" class="status" role="status"></p> <p id="login-status" class="status" role="status"></p>
<p><small>A mistyped password sends a reset link to your verified email address.</small></p>
</form> </form>
</section> </section>
</main> </main>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<!-- Copyright © 2026 Olaf Kolkman -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Reset password - LinkLog</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="container">
<section class="link-item settings-panel">
<h1>Reset password</h1>
<form id="reset-password-form">
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required /></label>
<button type="submit">Reset password</button>
<p id="reset-password-status" class="status" role="status"></p>
</form>
</section>
</main>
<script src="/static/reset-password.js"></script>
</body>
</html>
+7 -4
View File
@@ -29,13 +29,16 @@
<label>Password<input name="password" type="password" autocomplete="new-password" minlength="8" required /></label> <label>Password<input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
<h2>SMTP</h2> <h2>SMTP</h2>
<label>SMTP host<input name="smtp_host" type="text" required /></label> <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 port<input name="smtp_port" type="number" min="1" max="65535" required /></label>
<label>SMTP username<input name="smtp_username" type="text" autocomplete="off" /></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>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>From address<input name="smtp_from" type="text" required /></label>
<label class="checkbox-label"><input name="smtp_use_tls" type="checkbox" checked /> Use STARTTLS</label> <label class="checkbox-label"><input name="smtp_use_tls" type="checkbox" /> Use STARTTLS</label>
<button type="submit">Save and send test mail</button> <button type="submit">Save configuration</button>
<button id="test-mail-button" type="button" disabled>Send test mail</button>
<button id="complete-setup-button" type="button" hidden>Complete setup</button>
<p id="setup-status" class="status" role="status"></p> <p id="setup-status" class="status" role="status"></p>
<p id="setup-timer" class="status" role="timer" aria-live="polite"></p>
</form> </form>
</section> </section>
</main> </main>