diff --git a/README.md b/README.md index 1070757..bd7dca3 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ After logging in, open , enter the Mastodon insta `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. +LinkLog caches the OAuth application credentials per Mastodon server in the persistent SQLite `app_settings` table, so subsequent connections do not register a new application on every attempt. If the server rate-limits application registration, the profile page reports the upstream `429` response and the user can retry after the server's cooldown. + 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 index d2bc6a3..9a1d772 100644 --- a/backend/app/api/mastodon.py +++ b/backend/app/api/mastodon.py @@ -2,6 +2,7 @@ ## SPDX-License-Identifier: GPL-3.0-or-later from urllib.parse import quote +from urllib.error import HTTPError from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import RedirectResponse @@ -17,6 +18,14 @@ router = APIRouter() def oauth_start(instance: str = 'mastodon.social', user: dict = Depends(get_current_user)): try: authorization_url = start_authorization(user['id'], instance) + except HTTPError as error: + retry_after = error.headers.get('Retry-After') if error.headers else None + headers = {'Retry-After': retry_after} if retry_after else None + raise HTTPException( + status_code=error.code, + detail=f'Mastodon returned HTTP {error.code} while registering LinkLog. Try again later.', + headers=headers, + ) from error except Exception as error: raise HTTPException(status_code=502, detail=f'Could not register with Mastodon: {error}') from error return {'authorization_url': authorization_url} diff --git a/backend/app/services/mastodon_oauth.py b/backend/app/services/mastodon_oauth.py index bcc9442..7a2dcd9 100644 --- a/backend/app/services/mastodon_oauth.py +++ b/backend/app/services/mastodon_oauth.py @@ -6,6 +6,7 @@ from hashlib import sha256 import json from secrets import token_urlsafe from urllib.parse import urlencode +from urllib.error import HTTPError from urllib.request import Request, urlopen from uuid import uuid4 @@ -34,12 +35,24 @@ def post_form(url: str, values: dict) -> dict: 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, - }) + setting_name = f'mastodon_app:{instance}' + with get_connection() as conn: + row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone() + app = json.loads(row['value']) if row else None + if not app: + 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, + }) + 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''', + (setting_name, json.dumps(app)), + ) + conn.commit() state = token_urlsafe(32) expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.mastodon_oauth_expiry_minutes) with get_connection() as conn: diff --git a/frontend/static/profile.js b/frontend/static/profile.js index d578ef3..2de2216 100644 --- a/frontend/static/profile.js +++ b/frontend/static/profile.js @@ -111,7 +111,7 @@ mastodonConnectButton.addEventListener('click', async () => { setStatus('#mastodon-status', `Authorizing with ${instance}...`); 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.'); + if (!response.ok || !result.authorization_url) throw new Error(result.detail || result.error || 'Could not start Mastodon authorization.'); window.location.assign(result.authorization_url); } catch (error) { setStatus('#mastodon-status', error.message, true);