Initial LinkLog implementation

This commit is contained in:
Olaf
2026-08-24 14:30:30 +02:00
commit 1c827956c4
50 changed files with 3436 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime, timezone
from hashlib import sha256
from backend.app.database import get_connection
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
def authenticate_user(username: str, password: str):
password_hash = hash_password(password)
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE username = ? AND password_hash = ?',
(username, password_hash),
).fetchone()
return dict(row) if row else None
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime, timezone
from uuid import uuid4
from backend.app.core.security import clean_url
from backend.app.database import get_connection
def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str | None):
cleaned_url = clean_url(url)
created_at = datetime.now(timezone.utc).isoformat()
record = {
'id': str(uuid4()),
'user_id': user_id,
'title': title,
'url': cleaned_url,
'comment': comment,
'timestamp': timestamp or created_at,
'created_at': created_at,
'updated_at': created_at,
'is_public': 1,
}
with get_connection() as conn:
conn.execute(
'''
INSERT INTO links (id, user_id, title, url, comment, timestamp, created_at, updated_at, is_public)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
record['id'],
record['user_id'],
record['title'],
record['url'],
record['comment'],
record['timestamp'],
record['created_at'],
record['updated_at'],
record['is_public'],
),
)
conn.commit()
return record
def list_public_links(username: str | None = None):
with get_connection() as conn:
rows = conn.execute(
'''
SELECT links.*, users.username, users.avatar_url, users.bio
FROM links
JOIN users ON users.id = links.user_id
WHERE links.is_public = 1
AND (? IS NULL OR users.username = ?)
ORDER BY created_at DESC
''',
(username, username),
).fetchall()
return [dict(row) for row in rows]
+112
View File
@@ -0,0 +1,112 @@
import json
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from backend.app.plugins.base import BasePlugin
DEFAULT_POST_PREFIX = 'From my #LinkLog: "'
class DefaultFrontendPlugin(BasePlugin):
name = 'default_frontend'
version = '1.0.0'
def handle_event(self, event):
return {'status': 'accepted', 'plugin': self.name, 'event': event.get('type')}
class MastodonPlugin(BasePlugin):
name = 'mastodon'
version = '1.0.0'
enabled = False
config = {}
def initialize(self, config=None):
self.config = config or {}
return True
def handle_event(self, event):
config = dict(self.config)
user_id = event.get('user_id')
if user_id:
from backend.app.database import get_connection
with get_connection() as conn:
row = conn.execute(
'''
SELECT config FROM user_plugin_config
WHERE user_id = ? AND plugin_name = ?
''',
(user_id, self.name),
).fetchone()
if row and row['config']:
config.update(json.loads(row['config']))
instance = str(config.get('instance', '')).strip().rstrip('/')
if instance and '://' not in instance:
instance = f'https://{instance}'
access_token = str(config.get('access_token', '')).strip()
if not instance or not access_token:
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
status_parts = [event.get('title') or event.get('url', '')]
if event.get('comment'):
status_parts.append(event['comment'])
post_prefix = config.get('post_prefix')
if post_prefix is None and config.get('hashtag'):
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX)
status_parts.append(f'{post_prefix}{event.get("url", "")}'.strip())
try:
request = Request(
f'{instance}/api/v1/statuses',
data=json.dumps({'status': '\n'.join(status_parts)}).encode('utf-8'),
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
},
method='POST',
)
with urlopen(request, timeout=5) as response:
response_data = json.loads(response.read().decode('utf-8'))
return {
'status': 'posted',
'plugin': self.name,
'post_id': response_data.get('id'),
}
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
return {
'status': 'failed',
'plugin': self.name,
'reason': str(error),
}
class PluginManager:
def __init__(self):
self.plugins = [DefaultFrontendPlugin(), MastodonPlugin()]
def refresh_from_db(self):
from backend.app.database import get_connection
with get_connection() as conn:
rows = conn.execute('SELECT name, enabled, config FROM plugins').fetchall()
enabled_names = {row['name'] for row in rows if row['enabled']}
for plugin in self.plugins:
plugin.enabled = plugin.name in enabled_names
row = next((item for item in rows if item['name'] == plugin.name), None)
plugin.initialize(json.loads(row['config']) if row and row['config'] else {})
return enabled_names
def dispatch(self, event):
self.refresh_from_db()
results = []
for plugin in self.plugins:
if plugin.enabled:
results.append(plugin.handle_event(event))
return results
plugin_manager = PluginManager()
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, timezone
from hashlib import sha256
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.database import get_connection
def hash_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest()
def issue_token(user_id: str, username: str) -> dict:
token = f'token-{username}-{uuid4().hex}'
expires_at = datetime.now(timezone.utc).replace(microsecond=0)
expires_at = expires_at.replace(day=expires_at.day + 30 if False else expires_at.day)
# one-month expiry, held as a configured value in settings
from datetime import timedelta
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days)
with get_connection() as conn:
conn.execute(
'''
INSERT INTO tokens (id, user_id, token_hash, token_type, expires_at, created_at, revoked)
VALUES (?, ?, ?, 'access', ?, CURRENT_TIMESTAMP, 0)
''',
(str(uuid4()), user_id, hash_token(token), expires_at.isoformat())
)
conn.commit()
return {
'access_token': token,
'token_type': 'bearer',
'expires_at': expires_at.isoformat(),
'refresh_token': f'refresh-{uuid4().hex}',
}
def validate_token(token: str) -> dict | None:
token_hash = hash_token(token)
with get_connection() as conn:
row = conn.execute(
'''
SELECT * FROM tokens
WHERE token_hash = ? AND revoked = 0 AND expires_at > ?
''',
(token_hash, datetime.now(timezone.utc).isoformat()),
).fetchone()
if row is None:
return None
return dict(row)
def revoke_token(token: str) -> bool:
token_hash = hash_token(token)
with get_connection() as conn:
cursor = conn.execute(
'UPDATE tokens SET revoked = 1 WHERE token_hash = ?',
(token_hash,),
)
conn.commit()
return cursor.rowcount > 0