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