MAstodon posts fixed
Build LinkLog Development Image / development-image (push) Successful in 11s

This commit is contained in:
2026-08-26 09:26:16 +02:00
parent 333aab8e8a
commit 22add753fe
12 changed files with 682831 additions and 35 deletions
+5 -1
View File
@@ -2,6 +2,7 @@
## SPDX-License-Identifier: GPL-3.0-or-later
from fastapi import APIRouter, Header, HTTPException, status
import logging
from pydantic import BaseModel
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link
@@ -9,6 +10,7 @@ from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token
router = APIRouter()
logger = logging.getLogger(__name__)
class LinkCreate(BaseModel):
@@ -44,7 +46,9 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
plugin_manager.dispatch({'type': 'link_created', **record})
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
if any(result.get('status') == 'failed' for result in plugin_results):
logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results)
return record
+10 -1
View File
@@ -10,6 +10,14 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
def normalize_public_url(value: str) -> str:
value = value.strip().rstrip('/')
if '://' in value:
return value
scheme = 'http' if value.startswith(('localhost', '127.0.0.1')) else 'https'
return f'{scheme}://{value}'
@dataclass
class Settings:
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
@@ -17,7 +25,7 @@ class Settings:
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me')
token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30'))
public_url: str = os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000').rstrip('/')
public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000'))
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587'))
smtp_username: str = os.getenv('LINKLOG_SMTP_USERNAME', '')
@@ -28,6 +36,7 @@ class Settings:
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'))
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
tracking_params: list[str] = None
def __post_init__(self):
+3
View File
@@ -2,6 +2,7 @@
## SPDX-License-Identifier: GPL-3.0-or-later
from fastapi import FastAPI
import logging
from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -20,6 +21,8 @@ from backend.app.core.config import settings
from backend.app.database import AVATARS_DIR
from backend.app.services.link_service import list_public_links
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
app = FastAPI(title='LinkLog API', version=settings.version)
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
+41 -6
View File
@@ -2,12 +2,15 @@
## SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from backend.app.plugins.base import BasePlugin
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
logger = logging.getLogger(__name__)
class DefaultFrontendPlugin(BasePlugin):
@@ -50,6 +53,10 @@ class MastodonPlugin(BasePlugin):
instance = f'https://{instance}'
access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token:
logger.debug(
'Mastodon post skipped: instance_configured=%s token_configured=%s user_id=%s',
bool(instance), bool(access_token), user_id,
)
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
status_parts = [event.get('title') or event.get('url', '')]
@@ -63,25 +70,51 @@ class MastodonPlugin(BasePlugin):
if event.get('tags'):
status = f'{status} {" ".join(event["tags"])}'
status_parts.append(status)
post_body = '\n'.join(status_parts)
endpoint = f'{instance}/api/v1/statuses'
logger.debug(
'Posting link to Mastodon: endpoint=%s user_id=%s event_id=%s body_length=%d',
endpoint, user_id, event.get('id'), len(post_body),
)
try:
request = Request(
f'{instance}/api/v1/statuses',
data=json.dumps({'status': '\n'.join(status_parts)}).encode('utf-8'),
endpoint,
data=urlencode({'status': post_body}).encode('utf-8'),
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'User-Agent': 'LinkLog/1.0',
},
method='POST',
)
with urlopen(request, timeout=5) as response:
response_data = json.loads(response.read().decode('utf-8'))
response_body = response.read().decode('utf-8')
logger.debug(
'Mastodon post response: endpoint=%s status=%s body_length=%d',
endpoint, response.status, len(response_body),
)
response_data = json.loads(response_body)
logger.info('Mastodon post succeeded: instance=%s user_id=%s post_id=%s', instance, user_id, response_data.get('id'))
return {
'status': 'posted',
'plugin': self.name,
'post_id': response_data.get('id'),
}
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
except HTTPError as error:
response_body = error.read().decode('utf-8', errors='replace')
logger.warning(
'Mastodon post failed: endpoint=%s user_id=%s status=%s response=%s',
endpoint, user_id, error.code, response_body[:500],
)
return {
'status': 'failed',
'plugin': self.name,
'reason': f'HTTP {error.code}: {response_body[:500]}',
}
except (URLError, TimeoutError, OSError, ValueError) as error:
logger.exception('Mastodon post failed: endpoint=%s user_id=%s error=%s', endpoint, user_id, error)
return {
'status': 'failed',
'plugin': self.name,
@@ -111,7 +144,9 @@ class PluginManager:
results = []
for plugin in self.plugins:
if plugin.enabled:
results.append(plugin.handle_event(event))
result = plugin.handle_event(event)
results.append(result)
logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result)
return results
+5 -4
View File
@@ -4,6 +4,7 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
from uuid import uuid4
from unittest.mock import patch
@@ -410,7 +411,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
def do_POST(self):
received['path'] = self.path
received['authorization'] = self.headers['Authorization']
received['body'] = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
received['content_type'] = self.headers['Content-Type']
received['body'] = parse_qs(self.rfile.read(int(self.headers['Content-Length'])).decode())
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
@@ -442,9 +444,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
assert response.status_code == 201
assert received['path'] == '/api/v1/statuses'
assert received['authorization'] == 'Bearer test-token'
assert received['body'] == {
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: https://example.com/useful #python #web',
}
assert received['content_type'] == 'application/x-www-form-urlencoded'
assert received['body'] == {'status': ['A useful page\nWorth sharing\nFrom my #LinkLog: https://example.com/useful #python #web']}
finally:
server.shutdown()
thread.join()