Edit of links and database versioning
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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.link_service import create_link, list_public_links, update_link
|
||||
from backend.app.services.plugin_manager import plugin_manager
|
||||
from backend.app.services.token_service import validate_token
|
||||
|
||||
@@ -15,6 +15,12 @@ class LinkCreate(BaseModel):
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class LinkUpdate(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
comment: str = ''
|
||||
|
||||
|
||||
@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 '):
|
||||
@@ -29,6 +35,24 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
|
||||
return record
|
||||
|
||||
|
||||
@router.put('/links/{link_id}')
|
||||
def update_link_endpoint(
|
||||
link_id: str,
|
||||
payload: LinkUpdate,
|
||||
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')
|
||||
info = validate_token(authorization.replace('Bearer ', '', 1))
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
|
||||
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||
return record
|
||||
|
||||
|
||||
@router.get('/links')
|
||||
def list_links():
|
||||
return list_public_links()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend.app.services.link_service import list_public_links, list_public_users
|
||||
from backend.app.services.token_service import validate_token
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
@@ -12,7 +15,15 @@ def public_users():
|
||||
|
||||
@router.get('/feed')
|
||||
@router.get('/feed/{username}')
|
||||
def public_feed(username: str | None = None):
|
||||
def public_feed(
|
||||
username: str | None = None,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
):
|
||||
current_user_id = None
|
||||
if credentials:
|
||||
token_data = validate_token(credentials.credentials)
|
||||
if token_data:
|
||||
current_user_id = token_data['user_id']
|
||||
items = list_public_links(username)
|
||||
return [
|
||||
{
|
||||
@@ -25,6 +36,8 @@ def public_feed(username: str | None = None):
|
||||
'avatar_url': item['avatar_url'],
|
||||
'bio': item['bio'],
|
||||
},
|
||||
'is_owner': item['user_id'] == current_user_id,
|
||||
'can_edit': item['user_id'] == current_user_id,
|
||||
'created_at': item['created_at'],
|
||||
}
|
||||
for item in items
|
||||
|
||||
+19
-3
@@ -13,7 +13,8 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def hash_password(password: str) -> str:
|
||||
return sha256(password.encode('utf-8')).hexdigest()
|
||||
|
||||
SCHEMA = '''
|
||||
MIGRATIONS = [
|
||||
(1, '''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -70,7 +71,8 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
|
||||
UNIQUE(user_id, plugin_name),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
'''
|
||||
'''),
|
||||
]
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
@@ -80,9 +82,23 @@ def get_connection() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
def get_schema_version(conn: sqlite3.Connection) -> int:
|
||||
return conn.execute('PRAGMA user_version').fetchone()[0]
|
||||
|
||||
|
||||
def apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
current_version = get_schema_version(conn)
|
||||
for version, sql in MIGRATIONS:
|
||||
if version <= current_version:
|
||||
continue
|
||||
conn.executescript(sql)
|
||||
conn.execute(f'PRAGMA user_version = {version}')
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with get_connection() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
apply_migrations(conn)
|
||||
alice_hash = hash_password('secret123')
|
||||
bob_hash = hash_password('secret123')
|
||||
conn.execute(
|
||||
|
||||
@@ -57,6 +57,24 @@ def list_public_links(username: str | None = None):
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
|
||||
cleaned_url = clean_url(url)
|
||||
with get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
'''
|
||||
UPDATE links
|
||||
SET title = ?, url = ?, comment = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
''',
|
||||
(title, cleaned_url, comment, link_id, user_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
conn.commit()
|
||||
row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def list_public_users():
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -111,6 +111,47 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
||||
assert 'alice' in users_response.json()
|
||||
|
||||
|
||||
def test_only_link_owner_can_edit_link():
|
||||
owner_headers = login_headers('alice')
|
||||
response = client.post('/api/links', headers=owner_headers, json={
|
||||
'title': 'Editable link',
|
||||
'url': 'https://example.com/editable?utm_source=test',
|
||||
'comment': 'Before edit',
|
||||
})
|
||||
assert response.status_code == 201
|
||||
link_id = response.json()['id']
|
||||
|
||||
unauthenticated = client.put(f'/api/links/{link_id}', json={
|
||||
'title': 'Not allowed',
|
||||
'url': 'https://example.com/not-allowed',
|
||||
})
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
anonymous_feed = client.get('/api/public/feed').json()
|
||||
anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id)
|
||||
assert anonymous_link['is_owner'] is False
|
||||
assert anonymous_link['can_edit'] is False
|
||||
|
||||
owner_feed = client.get('/api/public/feed', headers=owner_headers).json()
|
||||
owner_link = next(item for item in owner_feed if item['id'] == link_id)
|
||||
assert owner_link['is_owner'] is True
|
||||
assert owner_link['can_edit'] is True
|
||||
|
||||
edited = client.put(f'/api/links/{link_id}', headers=owner_headers, json={
|
||||
'title': 'Edited link',
|
||||
'url': 'https://example.com/edited',
|
||||
'comment': 'After edit',
|
||||
})
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()['title'] == 'Edited link'
|
||||
|
||||
denied = client.put(f'/api/links/{link_id}', headers=login_headers('bob'), json={
|
||||
'title': 'Not allowed',
|
||||
'url': 'https://example.com/not-allowed',
|
||||
})
|
||||
assert denied.status_code == 404
|
||||
|
||||
|
||||
def test_logout_revokes_token_and_admin_can_list_plugins():
|
||||
headers = login_headers()
|
||||
token = headers['Authorization'].removeprefix('Bearer ')
|
||||
@@ -149,6 +190,8 @@ def test_public_and_admin_pages_render_html():
|
||||
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
|
||||
feed_script = client.get('/static/feed.js?v=5').text
|
||||
assert 'if (item.is_owner)' in feed_script
|
||||
|
||||
|
||||
def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import sqlite3
|
||||
|
||||
from backend.app.database import apply_migrations, get_schema_version
|
||||
|
||||
|
||||
def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 1
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
assert {'users', 'tokens', 'links', 'plugins', 'user_plugin_config'} <= tables
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 1
|
||||
|
||||
connection.close()
|
||||
Reference in New Issue
Block a user