diff --git a/.env.example b/.env.example index 88d8b00..f55a247 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ LINKLOG_SMTP_FROM=LinkLog LINKLOG_SMTP_USE_TLS=true LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24 LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1 +LINKLOG_MASTODON_CLIENT_NAME=LinkLog +LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10 # Optional comma-separated override. Leave empty to use the built-in list. LINKLOG_TRACKING_PARAMS= # Keep the default path when using the named linklog_data volume. diff --git a/README.md b/README.md index 48cdc60..1070757 100644 --- a/README.md +++ b/README.md @@ -226,12 +226,13 @@ For a real deployment, start with the docker-compose-example.yaml file. replace ## Mastodon Configuration -After logging in, open and save the Mastodon settings: +After logging in, open , enter the Mastodon instance, and select **Connect Mastodon**. LinkLog registers an OAuth application on that instance, opens Mastodon authorization, and stores the returned per-user access token after the callback. The requested scopes are `read:accounts` and `write:statuses`. - **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social` -- **Access token**: a Mastodon API token with permission to create statuses - **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: ` +`LINKLOG_PUBLIC_URL` must be the URL users can reach for the OAuth callback. For local development it can remain `http://localhost:8000`; for a deployment it must be the public LinkLog URL. Existing manually entered access tokens remain compatible with the plugin configuration API. + Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link. ## Useful API Calls diff --git a/backend/app/api/mastodon.py b/backend/app/api/mastodon.py new file mode 100644 index 0000000..d2bc6a3 --- /dev/null +++ b/backend/app/api/mastodon.py @@ -0,0 +1,33 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import RedirectResponse +from starlette.requests import Request + +from backend.app.api.dependencies import get_current_user +from backend.app.services.mastodon_oauth import finish_authorization, start_authorization + +router = APIRouter() + + +@router.get('/oauth/start') +def oauth_start(instance: str = 'mastodon.social', user: dict = Depends(get_current_user)): + try: + authorization_url = start_authorization(user['id'], instance) + except Exception as error: + raise HTTPException(status_code=502, detail=f'Could not register with Mastodon: {error}') from error + return {'authorization_url': authorization_url} + + +@router.get('/oauth/callback') +def oauth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None): + if error or not code or not state: + return RedirectResponse(f'/profile?mastodon_error={quote(error or "Authorization was cancelled")}') + try: + finish_authorization(code, state) + except Exception as callback_error: + return RedirectResponse(f'/profile?mastodon_error={quote(str(callback_error))}') + return RedirectResponse('/profile?mastodon=connected') \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 335f3d9..7c2ebb6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,8 @@ class Settings: 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')) password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1')) + mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog') + mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10')) tracking_params: list[str] = None def __post_init__(self): diff --git a/backend/app/database.py b/backend/app/database.py index e6caca3..4a215e0 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -135,6 +135,22 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens ( ); CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +'''), + (8, ''' +CREATE TABLE IF NOT EXISTS mastodon_oauth_states ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + state_hash TEXT NOT NULL UNIQUE, + instance TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + 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_mastodon_oauth_states_state_hash + ON mastodon_oauth_states(state_hash); '''), ] diff --git a/backend/app/main.py b/backend/app/main.py index f701172..932f21c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from starlette.requests import Request 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.mastodon import router as mastodon_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 @@ -24,6 +25,7 @@ app.mount('/static', StaticFiles(directory='frontend/static'), name='static') app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media') app.include_router(auth_router, prefix='/api/auth') app.include_router(links_router, prefix='/api') +app.include_router(mastodon_router, prefix='/api/mastodon') 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') diff --git a/backend/app/services/mastodon_oauth.py b/backend/app/services/mastodon_oauth.py new file mode 100644 index 0000000..bcc9442 --- /dev/null +++ b/backend/app/services/mastodon_oauth.py @@ -0,0 +1,106 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import datetime, timedelta, timezone +from hashlib import sha256 +import json +from secrets import token_urlsafe +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from uuid import uuid4 + +from backend.app.core.config import settings +from backend.app.database import get_connection + + +def normalize_instance(instance: str) -> str: + value = instance.strip().rstrip('/') + if not value: + raise ValueError('Mastodon instance is required') + return value if '://' in value else f'https://{value}' + + +def post_form(url: str, values: dict) -> dict: + request = Request( + url, + data=urlencode(values).encode('utf-8'), + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + method='POST', + ) + with urlopen(request, timeout=10) as response: + return json.loads(response.read().decode('utf-8')) + + +def start_authorization(user_id: str, instance: str) -> str: + instance = normalize_instance(instance) + redirect_uri = f'{settings.public_url}/api/mastodon/oauth/callback' + app = post_form(f'{instance}/api/v1/apps', { + 'client_name': settings.mastodon_client_name, + 'redirect_uris': redirect_uri, + 'scopes': 'read:accounts write:statuses', + 'website': settings.public_url, + }) + state = token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.mastodon_oauth_expiry_minutes) + with get_connection() as conn: + conn.execute('DELETE FROM mastodon_oauth_states WHERE user_id = ?', (user_id,)) + conn.execute( + '''INSERT INTO mastodon_oauth_states + (id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', + (str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance, + app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()), + ) + conn.commit() + return f'{instance}/oauth/authorize?' + urlencode({ + 'client_id': app['client_id'], + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': 'read:accounts write:statuses', + 'state': state, + }) + + +def finish_authorization(code: str, state: str) -> str: + now = datetime.now(timezone.utc).isoformat() + with get_connection() as conn: + record = conn.execute( + '''SELECT * FROM mastodon_oauth_states + WHERE state_hash = ? AND expires_at > ?''', + (sha256(state.encode()).hexdigest(), now), + ).fetchone() + if record is None: + raise ValueError('OAuth state is invalid or expired') + conn.execute('DELETE FROM mastodon_oauth_states WHERE id = ?', (record['id'],)) + conn.commit() + token = post_form(f"{record['instance']}/oauth/token", { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': record['client_id'], + 'client_secret': record['client_secret'], + 'redirect_uri': record['redirect_uri'], + }) + access_token = token.get('access_token') + if not access_token: + raise ValueError('Mastodon did not return an access token') + with get_connection() as conn: + current = conn.execute( + 'SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?', + (record['user_id'], 'mastodon'), + ).fetchone() + config = json.loads(current['config']) if current and current['config'] else {} + config.update({'instance': record['instance'], 'access_token': access_token}) + if current: + conn.execute( + 'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?', + (json.dumps(config), record['user_id'], 'mastodon'), + ) + else: + conn.execute( + '''INSERT INTO user_plugin_config + (id, user_id, plugin_name, config, created_at, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)''', + (str(uuid4()), record['user_id'], 'mastodon', json.dumps(config)), + ) + conn.commit() + return record['user_id'] \ No newline at end of file diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 4973e62..cd188f4 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) == 7 + assert get_schema_version(connection) == 8 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) == 7 + assert get_schema_version(connection) == 8 connection.close() \ No newline at end of file diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 8f8534f..9a879e5 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -23,6 +23,8 @@ services: LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1} + LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog} + LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} restart: ${APP_RESTART_POLICY:-unless-stopped} healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index e87eb5f..e73654c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1} + LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog} + LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} restart: ${APP_RESTART_POLICY:-unless-stopped} healthcheck: diff --git a/frontend/static/profile.js b/frontend/static/profile.js index ccd1c0e..b8a6adf 100644 --- a/frontend/static/profile.js +++ b/frontend/static/profile.js @@ -9,6 +9,7 @@ const profileLogoutButton = document.querySelector('#logout-button'); const accessToken = localStorage.getItem('linklogAccessToken'); const defaultMastodonInstance = 'mastodon.social'; const defaultPostPrefix = 'From my #LinkLog: '; +const mastodonConnectButton = document.querySelector('#mastodon-connect'); function authHeaders(includeJson = false) { return { @@ -46,7 +47,6 @@ async function loadMastodonConfig() { if (!response.ok) throw new Error('Could not load Mastodon settings'); const config = await response.json(); document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance; - document.querySelector('#mastodon-access-token').value = config.access_token || ''; document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix; } @@ -94,6 +94,24 @@ mastodonForm.addEventListener('submit', async (event) => { setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok); }); +mastodonConnectButton.addEventListener('click', async () => { + mastodonConnectButton.disabled = true; + const instance = document.querySelector('#mastodon-instance').value || defaultMastodonInstance; + try { + const response = await fetch(`/api/mastodon/oauth/start?instance=${encodeURIComponent(instance)}`, {headers: authHeaders()}); + const result = await response.json(); + if (!response.ok || !result.authorization_url) throw new Error(result.error || 'Could not start Mastodon authorization.'); + window.location.assign(result.authorization_url); + } catch (error) { + setStatus('#mastodon-status', error.message, true); + mastodonConnectButton.disabled = false; + } +}); + +const mastodonParams = new URLSearchParams(window.location.search); +if (mastodonParams.get('mastodon') === 'connected') setStatus('#mastodon-status', 'Mastodon connected.'); +if (mastodonParams.get('mastodon_error')) setStatus('#mastodon-status', mastodonParams.get('mastodon_error'), true); + passwordForm.addEventListener('submit', async (event) => { event.preventDefault(); const response = await fetch('/api/user/password', { diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index dba85a6..cd450e6 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -84,14 +84,11 @@ Instance - +

@@ -100,6 +97,6 @@ - +