This commit is contained in:
@@ -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,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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user