## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import json from fastapi import APIRouter, Depends, Header, HTTPException, Response, status import logging from pydantic import BaseModel from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link from backend.app.database import get_connection from backend.app.services.plugin_manager import plugin_manager from backend.app.services.token_service import validate_token from backend.app.services.audit_service import record_audit_event from backend.app.services.scraper_service import scrape_title router = APIRouter() logger = logging.getLogger(__name__) class LinkCreate(BaseModel): title: str url: str comment: str = '' timestamp: str | None = None tags: list[str] = [] post_to_mastodon: bool = True class LinkUpdate(BaseModel): title: str url: str comment: str = '' tags: list[str] = [] @router.get('/tags') def available_tags(): return list_tags() @router.get('/scrape') def scrape_url(url: str, authorization: str | None = Header(default=None)): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') try: title = scrape_title(url) return {'title': title} except Exception as e: logger.error('Scrape error for %s: %s', url, e) raise HTTPException(status_code=502, detail='Could not scrape URL') from e @router.get('/links/check') def check_existing_link( title: str, url: str, authorization: str | None = Header(default=None), ): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') record = find_owned_link_by_title_url(info['user_id'], title, url) if record is not None: return {'exists': True, 'url_matches': True, 'stored_url': record['url']} record = find_owned_link_by_title(info['user_id'], title) if record is not None: return {'exists': True, 'url_matches': False, 'stored_url': record['url']} return {'exists': False, 'url_matches': None, 'stored_url': None} @router.post('/links', status_code=status.HTTP_201_CREATED) def create_link_endpoint(payload: LinkCreate, response: Response, authorization: str | None = Header(default=None)): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') token = authorization.replace('Bearer ', '', 1) info = validate_token(token) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') try: record = find_owned_link_by_title_url(info['user_id'], payload.title, payload.url) duplicate = record is not None if duplicate: record = update_link(record['id'], info['user_id'], payload.title, payload.url, payload.comment, payload.tags) else: 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 if duplicate: response.status_code = status.HTTP_200_OK plugin_results = plugin_manager.dispatch({ 'type': 'link_created', 'post_to_mastodon': payload.post_to_mastodon, **record, }) mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None) if mastodon_result and mastodon_result.get('status') == 'posted': mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id')) 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) plugin_errors = [ {'plugin': result.get('plugin', 'unknown'), 'reason': result.get('reason', 'Plugin failed')} for result in plugin_results if result.get('status') == 'failed' ] return {**record, 'duplicate': duplicate, 'plugin_errors': plugin_errors} @router.put('/links/{link_id}') def update_link_endpoint( link_id: str, payload: LinkUpdate, authorization: str | None = Header(default=None), ): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') try: record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment, payload.tags) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) from error if record is None: raise HTTPException(status_code=404, detail='Link not found or not owned by user') return record @router.delete('/links/{link_id}') def delete_link_endpoint( link_id: str, authorization: str | None = Header(default=None), ): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') link = get_owned_link(link_id, info['user_id']) if link is None: raise HTTPException(status_code=404, detail='Link not found or not owned by user') post_ids = json.loads(link['mastodon_post_ids']) if link.get('mastodon_post_ids') else [] if not post_ids and link.get('mastodon_post_id'): post_ids = [link['mastodon_post_id']] if post_ids: result = plugin_manager.delete_mastodon_posts({**link, 'mastodon_post_ids': post_ids}) if result.get('status') != 'deleted': raise HTTPException(status_code=502, detail=result.get('reason', 'Could not delete Mastodon posts')) if not delete_link(link_id, info['user_id']): raise HTTPException(status_code=404, detail='Link not found or not owned by user') record_audit_event(info['user_id'], 'link_deleted', 'link', link_id) return {'status': 'deleted', 'id': link_id} @router.post('/links/{link_id}/mastodon') def post_link_to_mastodon( link_id: str, authorization: str | None = Header(default=None), ): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') with get_connection() as conn: row = conn.execute('SELECT * FROM links WHERE id = ? AND user_id = ?', (link_id, info['user_id'])).fetchone() if row is None: raise HTTPException(status_code=404, detail='Link not found or not owned by user') event = dict(row) with get_connection() as conn: event['tags'] = get_link_tags(conn, link_id) result = plugin_manager.post_to_mastodon({'type': 'link_created', **event}) if result.get('status') == 'posted': mark_mastodon_posted(link_id, info['user_id'], result.get('post_id')) record_audit_event(info['user_id'], 'mastodon_posted', 'link', link_id) return {'status': 'posted', 'post_id': result.get('post_id')} if result.get('status') == 'skipped': raise HTTPException(status_code=409, detail='Mastodon is not enabled or configured') raise HTTPException(status_code=502, detail=result.get('reason', 'Mastodon post failed')) @router.get('/links') def list_links(): return list_public_links()