first setup configuration
Build LinkLog Development Image / development-image (push) Successful in 10s
Build LinkLog Development Image / development-image (push) Successful in 10s
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
@@ -26,3 +26,17 @@ def test_send_verification_email_uses_smtp_settings(monkeypatch):
|
|||||||
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'
|
||||||
@@ -33,11 +33,6 @@ 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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 <no-reply@example.com>" 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>
|
||||||
Reference in New Issue
Block a user