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
+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