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
+1
View File
@@ -0,0 +1 @@
"""LinkLog backend package."""
+14
View File
@@ -0,0 +1,14 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.main import app
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request):
return templates.TemplateResponse('admin.html', {'request': request})
+202
View File
@@ -0,0 +1,202 @@
import json
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.app.api.dependencies import require_admin
from backend.app.database import get_connection, hash_password
router = APIRouter()
class AdminPluginUpdate(BaseModel):
enabled: bool | None = None
config: dict | None = None
class AdminUserCreate(BaseModel):
username: str
email: str
password: str
is_admin: bool = False
class AdminUserUpdate(BaseModel):
is_admin: bool
def public_user(row):
return {
'id': row['id'],
'username': row['username'],
'email': row['email'],
'is_admin': bool(row['is_admin']),
'avatar_url': row['avatar_url'],
'bio': row['bio'],
'created_at': row['created_at'],
}
@router.get('/users')
def list_users(_: dict = Depends(require_admin)):
with get_connection() as conn:
rows = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users ORDER BY username'
).fetchall()
return [public_user(row) for row in rows]
@router.post('/users', status_code=201)
def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
username = payload.username.strip()
email = payload.email.strip()
if not username or not email or len(payload.password) < 8:
raise HTTPException(status_code=422, detail='Username, email, and a password of at least 8 characters are required')
with get_connection() as conn:
try:
cursor = conn.execute(
'''
INSERT INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, ?)
''',
(str(uuid4()), username, email, hash_password(payload.password), int(payload.is_admin)),
)
conn.commit()
except Exception as error:
if 'UNIQUE constraint failed' in str(error):
raise HTTPException(status_code=409, detail='Username or email already exists') from error
raise
row = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE rowid = last_insert_rowid()'
).fetchone()
return public_user(row)
@router.put('/users/{user_id}')
def update_user_privileges(
user_id: str,
payload: AdminUserUpdate,
_: dict = Depends(require_admin),
):
with get_connection() as conn:
target = conn.execute(
'SELECT id, is_admin FROM users WHERE id = ?',
(user_id,),
).fetchone()
if target is None:
raise HTTPException(status_code=404, detail='User not found')
if target['is_admin'] and not payload.is_admin:
admin_count = conn.execute(
'SELECT COUNT(*) AS count FROM users WHERE is_admin = 1'
).fetchone()['count']
if admin_count <= 1:
raise HTTPException(status_code=400, detail='At least one administrator is required')
conn.execute(
'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(int(payload.is_admin), user_id),
)
conn.commit()
row = conn.execute(
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE id = ?',
(user_id,),
).fetchone()
return public_user(row)
@router.delete('/users/{user_id}')
def delete_user(user_id: str, current_user: dict = Depends(require_admin)):
if user_id == current_user['id']:
raise HTTPException(status_code=400, detail='You cannot delete your own account')
with get_connection() as conn:
target = conn.execute('SELECT id, is_admin FROM users WHERE id = ?', (user_id,)).fetchone()
if target is None:
raise HTTPException(status_code=404, detail='User not found')
if target['is_admin']:
admins = conn.execute('SELECT COUNT(*) AS count FROM users WHERE is_admin = 1').fetchone()['count']
if admins <= 1:
raise HTTPException(status_code=400, detail='Cannot delete the last administrator')
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
conn.commit()
return {'status': 'deleted', 'id': user_id}
@router.get('/plugins')
def list_plugins(_: dict = Depends(require_admin)):
with get_connection() as conn:
rows = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins ORDER BY name'
).fetchall()
return [
{
'id': row['id'],
'name': row['name'],
'version': row['version'],
'enabled': bool(row['enabled']),
'config': row['config'],
}
for row in rows
]
@router.get('/plugins/{plugin_name}')
def get_plugin(plugin_name: str, _: dict = Depends(require_admin)):
with get_connection() as conn:
row = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?',
(plugin_name,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='Plugin not found')
return {
'id': row['id'],
'name': row['name'],
'version': row['version'],
'enabled': bool(row['enabled']),
'config': json.loads(row['config']) if row['config'] else {},
}
@router.put('/plugins/{plugin_name}')
def update_plugin(
plugin_name: str,
payload: AdminPluginUpdate,
_: dict = Depends(require_admin),
):
with get_connection() as conn:
current = conn.execute(
'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?',
(plugin_name,),
).fetchone()
if current is None:
raise HTTPException(status_code=404, detail='Plugin not found')
enabled = payload.enabled if payload.enabled is not None else bool(current['enabled'])
config = json.loads(current['config']) if current['config'] else {}
if payload.config is not None:
config.update(payload.config)
conn.execute(
'''
UPDATE plugins
SET enabled = ?, config = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
''',
(1 if enabled else 0, json.dumps(config, ensure_ascii=False), plugin_name),
)
conn.commit()
return {
'name': plugin_name,
'enabled': enabled,
'config': config,
}
+61
View File
@@ -0,0 +1,61 @@
from uuid import uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from backend.app.database import get_connection, init_db
from backend.app.services.auth_service import authenticate_user
from backend.app.services.token_service import issue_token, revoke_token, validate_token
router = APIRouter()
init_db()
class LoginRequest(BaseModel):
username: str
password: str
@router.post('/login')
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail='Invalid username or password')
token_data = issue_token(user['id'], user['username'])
return {
'access_token': token_data['access_token'],
'token_type': 'bearer',
'expires_at': token_data['expires_at'],
'refresh_token': token_data['refresh_token'],
'user': {'id': user['id'], 'username': user['username'], 'email': user['email']}
}
@router.post('/logout')
def logout(payload: dict):
token = payload.get('token')
if not token:
raise HTTPException(status_code=400, detail='Token is required')
revoked = revoke_token(token)
if not revoked:
raise HTTPException(status_code=404, detail='Token not found or already revoked')
return {'status': 'logged_out'}
@router.get('/me')
def current_user(token: str):
info = validate_token(token)
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
with get_connection() as conn:
user = conn.execute('SELECT * FROM users WHERE id = ?', (info['user_id'],)).fetchone()
if user is None:
raise HTTPException(status_code=404, detail='User not found')
return {
'id': user['id'],
'username': user['username'],
'email': user['email'],
'is_admin': bool(user['is_admin']),
}
+41
View File
@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.app.database import get_connection
from backend.app.services.token_service import validate_token
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
):
if credentials is None or credentials.scheme.lower() != 'bearer':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Authentication required',
headers={'WWW-Authenticate': 'Bearer'},
)
token_data = validate_token(credentials.credentials)
if token_data is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Token expired or invalid',
headers={'WWW-Authenticate': 'Bearer'},
)
with get_connection() as conn:
user = conn.execute(
'SELECT * FROM users WHERE id = ?',
(token_data['user_id'],),
).fetchone()
if user is None:
raise HTTPException(status_code=404, detail='User not found')
return dict(user)
def require_admin(user: dict = Depends(get_current_user)):
if not user['is_admin']:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Administrator access required')
return user
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel
from backend.app.services.link_service import create_link, list_public_links
from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token
router = APIRouter()
class LinkCreate(BaseModel):
title: str
url: str
comment: str = ''
timestamp: str | None = None
@router.post('/links', status_code=status.HTTP_201_CREATED)
def create_link_endpoint(payload: LinkCreate, 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')
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp)
plugin_manager.dispatch({'type': 'link_created', **record})
return record
@router.get('/links')
def list_links():
return list_public_links()
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter
from backend.app.services.link_service import list_public_links
router = APIRouter()
@router.get('/feed')
@router.get('/feed/{username}')
def public_feed(username: str | None = None):
items = list_public_links(username)
return [
{
'id': item['id'],
'title': item['title'],
'url': item['url'],
'comment': item['comment'],
'user': {
'username': item['username'],
'avatar_url': item['avatar_url'],
'bio': item['bio'],
},
'created_at': item['created_at'],
}
for item in items
]
+116
View File
@@ -0,0 +1,116 @@
import json
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
from backend.app.database import get_connection
router = APIRouter()
class UserConfigUpdate(BaseModel):
email: str | None = None
bio: str | None = None
avatar_url: str | None = None
class UserPluginConfigUpdate(BaseModel):
instance: str | None = None
access_token: str | None = None
post_prefix: str | None = None
hashtag: str | None = None
@router.get('/me')
def get_current_user_profile(user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE id = ?',
(user['id'],),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='User not found')
return dict(row)
@router.put('/me')
def update_current_user_profile(
payload: UserConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute('SELECT * FROM users WHERE id = ?', (user['id'],)).fetchone()
if current is None:
raise HTTPException(status_code=404, detail='User not found')
email = payload.email or current['email']
bio = payload.bio if payload.bio is not None else current['bio']
avatar_url = payload.avatar_url if payload.avatar_url is not None else current['avatar_url']
conn.execute(
'''
UPDATE users
SET email = ?, bio = ?, avatar_url = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''',
(email, bio, avatar_url, user['id']),
)
conn.commit()
return {'status': 'updated'}
@router.get('/plugins/{plugin_name}')
def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
if row is None:
return {}
config = json.loads(row['config']) if row['config'] else {}
return config
@router.put('/plugins/{plugin_name}')
def update_user_plugin_config(
plugin_name: str,
payload: UserPluginConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
current_config = json.loads(current['config']) if current and current['config'] else {}
updates = payload.model_dump(exclude_none=True)
merged = {**current_config, **updates}
if current is None:
conn.execute(
'''
INSERT INTO user_plugin_config (id, user_id, plugin_name, config, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''',
(str(uuid4()), user['id'], plugin_name, json.dumps(merged, ensure_ascii=False)),
)
else:
conn.execute(
'''
UPDATE user_plugin_config
SET config = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND plugin_name = ?
''',
(json.dumps(merged, ensure_ascii=False), user['id'], plugin_name),
)
conn.commit()
return merged
+35
View File
@@ -0,0 +1,35 @@
from dataclasses import dataclass
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
DB_PATH = BASE_DIR / 'data' / 'linklog.db'
@dataclass
class Settings:
app_name: str = os.getenv('LINKLOG_APP_NAME', 'LinkLog')
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'))
tracking_params: list[str] = None
def __post_init__(self):
if self.tracking_params is None:
default_tracking_params = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid', 'mc_cid',
'mc_eid', 'igshid', 'ga_source', 'ga_medium', 'ga_campaign', 'ga_place',
'campaignid', 'hsa_cr', 'hsa_cam', 'hsa_grp', 'hsa_ad', 'hsa_src',
'hsa_acc', 'hsa_net', 'hsa_mt', 'hsa_kw', 'hsa_ver', 'hsa_tgt',
]
configured_params = os.getenv('LINKLOG_TRACKING_PARAMS')
self.tracking_params = (
[item.strip() for item in configured_params.split(',') if item.strip()]
if configured_params
else default_tracking_params
)
settings = Settings()
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from backend.app.core.config import settings
def hash_token(token: str) -> str:
return sha256(token.encode('utf-8')).hexdigest()
def token_expires_at() -> datetime:
return datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days)
def clean_url(url: str) -> str:
from urllib.parse import parse_qsl, urlsplit, urlunsplit
parts = urlsplit(url)
if not parts.query:
return url
params = parse_qsl(parts.query, keep_blank_values=True)
filtered = [(k, v) for k, v in params if k not in settings.tracking_params]
new_query = '&'.join(f'{k}={v}' if v != '' else k for k, v in filtered)
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
+115
View File
@@ -0,0 +1,115 @@
import sqlite3
import os
from hashlib import sha256
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
SCHEMA = '''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar_url TEXT,
bio TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_type TEXT NOT NULL DEFAULT 'access',
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
comment TEXT,
timestamp TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_public INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
version TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_plugin_config (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
plugin_name TEXT NOT NULL,
config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, plugin_name),
FOREIGN KEY(user_id) REFERENCES users(id)
);
'''
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA foreign_keys = ON')
return conn
def init_db() -> None:
with get_connection() as conn:
conn.executescript(SCHEMA)
alice_hash = hash_password('secret123')
bob_hash = hash_password('secret123')
conn.execute(
'''
INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, 1)
''',
('user-1', 'alice', 'alice@example.com', alice_hash)
)
conn.execute("UPDATE users SET is_admin = 1 WHERE id = 'user-1'")
conn.execute(
'''
INSERT OR IGNORE INTO users (id, username, email, password_hash, is_admin)
VALUES (?, ?, ?, ?, 0)
''',
('user-2', 'bob', 'bob@example.com', bob_hash)
)
conn.execute(
'''
INSERT OR IGNORE INTO plugins (id, name, version, enabled, config)
VALUES (?, ?, ?, 1, ?)
''',
('plugin-1', 'default_frontend', '1.0.0', '{"route": "/"}')
)
conn.execute(
'''
INSERT OR IGNORE INTO plugins (id, name, version, enabled, config)
VALUES (?, ?, ?, 1, ?)
''',
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
)
conn.commit()
+18
View File
@@ -0,0 +1,18 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.main import app
from backend.app.services.link_service import list_public_links
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/', response_class=HTMLResponse)
async def public_root(request: Request):
feed = list_public_links()
return templates.TemplateResponse('feed.html', {'request': request, 'feed': feed})
+65
View File
@@ -0,0 +1,65 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from backend.app.api.admin import router as admin_router
from backend.app.api.auth import router as auth_router
from backend.app.api.links import router as links_router
from backend.app.api.public import router as public_router
from backend.app.api.user_config import router as user_config_router
from backend.app.services.link_service import list_public_links
app = FastAPI(title='LinkLog API')
app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
app.include_router(auth_router, prefix='/api/auth')
app.include_router(links_router, prefix='/api')
app.include_router(public_router, prefix='/api/public')
app.include_router(admin_router, prefix='/api/admin')
app.include_router(user_config_router, prefix='/api/user')
templates = Jinja2Templates(directory='frontend/templates')
@app.get('/', response_class=HTMLResponse)
async def public_root(request: Request):
feed = list_public_links()
return templates.TemplateResponse(request, 'feed.html', {'feed': feed})
@app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request):
return templates.TemplateResponse(request, 'admin.html', {})
@app.get('/profile', response_class=HTMLResponse)
async def user_profile_page(request: Request):
return templates.TemplateResponse(request, 'user_profile.html', {})
@app.get('/login', response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, 'login.html', {})
@app.get('/health')
def health_check():
return {'status': 'ok'}
@app.get('/{username}', response_class=HTMLResponse)
async def public_user_feed(request: Request, username: str):
feed = list_public_links(username)
profile = None
if feed:
profile = {
'username': feed[0]['username'],
'avatar_url': feed[0]['avatar_url'],
'bio': feed[0]['bio'],
}
return templates.TemplateResponse(
request,
'feed.html',
{'feed': feed, 'profile': profile, 'user_filter': username},
)
+17
View File
@@ -0,0 +1,17 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class LinkRecord:
id: str
user_id: str
title: str
url: str
comment: str
created_at: datetime
timestamp: Optional[datetime] = None
is_public: bool = True
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
+16
View File
@@ -0,0 +1,16 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class UserRecord:
id: str
username: str
password_hash: str
email: Optional[str] = None
avatar_url: Optional[str] = None
bio: Optional[str] = None
is_admin: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
+16
View File
@@ -0,0 +1,16 @@
class BasePlugin:
name = 'base'
version = '1.0.0'
enabled = True
def initialize(self, config=None):
return True
def validate_config(self, config):
return True, 'ok'
def handle_event(self, event):
raise NotImplementedError
def health_check(self):
return {'name': self.name, 'status': 'ok'}
+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
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.141.1
uvicorn==0.52.4
pydantic==2.13.4
jinja2==3.1.6
pytest==9.1.1
httpx==0.28.1
httpx2==2.12.0
+207
View File
@@ -0,0 +1,207 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def login_headers(username='alice'):
token = client.post('/api/auth/login', json={
'username': username,
'password': 'secret123',
}).json()['access_token']
return {'Authorization': f'Bearer {token}'}
def test_login_returns_token():
response = client.post('/api/auth/login', json={
'username': 'alice',
'password': 'secret123',
})
assert response.status_code == 200
payload = response.json()
assert 'access_token' in payload
assert payload['token_type'] == 'bearer'
admin_session = client.get('/api/auth/me', params={'token': payload['access_token']})
assert admin_session.status_code == 200
assert admin_session.json()['is_admin'] is True
user_token = client.post('/api/auth/login', json={
'username': 'bob',
'password': 'secret123',
}).json()['access_token']
user_session = client.get('/api/auth/me', params={'token': user_token})
assert user_session.status_code == 200
assert user_session.json()['is_admin'] is False
def test_configuration_requires_authentication_and_admin_role():
assert client.get('/api/user/me').status_code == 401
assert client.get('/api/admin/plugins').status_code == 401
assert client.get('/api/admin/users').status_code == 401
assert client.get('/api/admin/plugins', headers=login_headers('bob')).status_code == 403
assert client.get('/api/admin/users', headers=login_headers('bob')).status_code == 403
def test_admin_can_add_list_and_remove_users():
headers = login_headers()
create_response = client.post('/api/admin/users', headers=headers, json={
'username': 'charlie',
'email': 'charlie@example.com',
'password': 'charlie-secret',
})
assert create_response.status_code == 201
user = create_response.json()
assert user['username'] == 'charlie'
assert 'password_hash' not in user
users = client.get('/api/admin/users', headers=headers).json()
assert any(item['id'] == user['id'] for item in users)
assert client.delete(f"/api/admin/users/{user['id']}", headers=headers).status_code == 200
assert all(item['id'] != user['id'] for item in client.get('/api/admin/users', headers=headers).json())
assert client.delete('/api/admin/users/user-1', headers=headers).status_code == 400
def test_admin_can_toggle_privileges_without_removing_last_admin():
headers = login_headers()
bob = next(user for user in client.get('/api/admin/users', headers=headers).json() if user['username'] == 'bob')
promote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': True})
assert promote.status_code == 200
assert promote.json()['is_admin'] is True
demote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': False})
assert demote.status_code == 200
assert demote.json()['is_admin'] is False
last_admin = client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False})
assert last_admin.status_code == 400
def test_submit_link_stores_cleaned_url_and_public_feed():
response = client.post('/api/links', headers=login_headers(), json={
'title': 'Example page',
'url': 'https://example.com/path?utm_source=ad&utm_medium=email&keep=yes',
'comment': 'Interesting read',
'timestamp': '2026-08-24T12:00:00Z',
})
assert response.status_code == 201
data = response.json()
assert data['url'] == 'https://example.com/path?keep=yes'
feed = client.get('/api/public/feed').json()
matching = next(item for item in feed if item['id'] == data['id'])
assert matching['comment'] == 'Interesting read'
assert matching['user']['username'] == 'alice'
filtered_response = client.get('/api/public/feed/alice')
assert filtered_response.status_code == 200
assert all(item['user']['username'] == 'alice' for item in filtered_response.json())
assert client.get('/api/public/feed/does-not-exist').json() == []
def test_logout_revokes_token_and_admin_can_list_plugins():
headers = login_headers()
token = headers['Authorization'].removeprefix('Bearer ')
assert client.post('/api/auth/logout', json={'token': token}).status_code == 200
revoked_response = client.post('/api/links', headers=headers, json={
'title': 'Should fail',
'url': 'https://example.com/path',
'comment': 'nope',
})
assert revoked_response.status_code == 401
plugins_response = client.get('/api/admin/plugins', headers=login_headers())
assert plugins_response.status_code == 200
assert any(plugin['name'] == 'default_frontend' for plugin in plugins_response.json())
def test_public_and_admin_pages_render_html():
root_page = client.get('/')
assert 'LinkLog' in root_page.text
assert 'class="login-button" href="/login"' in root_page.text
assert client.get('/alice').status_code == 200
assert 'alice' in client.get('/alice').text
assert client.get('/login').status_code == 200
assert 'Sign in' in client.get('/login').text
assert client.get('/admin').status_code == 200
admin_page = client.get('/admin').text
assert 'Admin' in admin_page
assert 'id="admin-controls" class="hidden"' in admin_page
assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page
assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page
assert 'id="logout-button" class="logout-button hidden"' in admin_page
def test_link_submission_posts_to_enabled_mastodon_plugin():
received = {}
class MastodonHandler(BaseHTTPRequestHandler):
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'])))
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"id":"status-1"}')
def log_message(self, format, *args):
return
server = ThreadingHTTPServer(('127.0.0.1', 0), MastodonHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
headers = login_headers()
base_url = f'http://127.0.0.1:{server.server_port}'
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': base_url,
'access_token': 'test-token',
'post_prefix': 'From my #LinkLog: "',
}).status_code == 200
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
response = client.post('/api/links', headers=headers, json={
'title': 'A useful page',
'url': 'https://example.com/useful',
'comment': 'Worth sharing',
})
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',
}
finally:
server.shutdown()
thread.join()
server.server_close()
def test_plugin_config_can_be_saved_for_mastodon():
headers = login_headers()
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
'instance': 'mastodon.social',
'access_token': 'demo-token',
'post_prefix': 'From my #LinkLog: "',
}).status_code == 200
payload = client.get('/api/user/plugins/mastodon', headers=headers).json()
assert payload['instance'] == 'mastodon.social'
assert payload['post_prefix'] == 'From my #LinkLog: "'
admin_update = client.put('/api/admin/plugins/mastodon', headers=headers, json={
'enabled': True,
'config': {'instance': 'mastodon.social'},
})
assert admin_update.status_code == 200
+39
View File
@@ -0,0 +1,39 @@
from fastapi.testclient import TestClient
from backend.app.main import app
client = TestClient(app)
def test_user_config_api_and_profile_page():
login = client.post('/api/auth/login', json={
'username': 'alice',
'password': 'secret123',
}).json()
headers = {'Authorization': f"Bearer {login['access_token']}"}
profile_response = client.get('/api/user/me', headers=headers)
assert profile_response.status_code == 200
payload = profile_response.json()
assert payload['username'] == 'alice'
update_response = client.put('/api/user/me', json={
'username': 'should-not-change',
'bio': 'Updated bio',
'avatar_url': 'https://example.com/new-avatar.png'
}, headers=headers)
assert update_response.status_code == 200
page_response = client.get('/profile')
assert page_response.status_code == 200
assert 'Profile' in page_response.text
assert '<output id="username" class="readonly-value">Loading...</output>' in page_response.text
assert 'name="username"' not in page_response.text
assert '<textarea id="bio" name="bio" rows="4"></textarea>' in page_response.text
assert 'https://example.com/avatar.png' in page_response.text
assert 'value="mastodon.social"' in page_response.text
assert 'From my #LinkLog: &quot;' in page_response.text
assert 'id="admin-link"' in page_response.text
assert 'class="login-button hidden"' in page_response.text
assert 'id="logout-button" class="logout-button hidden"' in page_response.text