From 28a01175f7850cac904852e5031518b18f8ac6c5 Mon Sep 17 00:00:00 2001 From: Olaf Date: Mon, 24 Aug 2026 19:39:21 +0200 Subject: [PATCH] Edit of links and database versioning --- README.md | 6 ++++ VIBE/CHAT_LOG.md | 30 +++++++++++++++++++ VIBE/PROMPTS.md | 5 ++++ backend/app/api/links.py | 26 ++++++++++++++++- backend/app/api/public.py | 17 +++++++++-- backend/app/database.py | 22 ++++++++++++-- backend/app/services/link_service.py | 18 ++++++++++++ backend/tests/test_api.py | 43 ++++++++++++++++++++++++++++ backend/tests/test_database.py | 22 ++++++++++++++ frontend/static/feed.js | 39 ++++++++++++++++++++++++- frontend/static/style.css | 17 +++++++++++ frontend/templates/feed.html | 2 +- 12 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_database.py diff --git a/README.md b/README.md index 35c80b0..ef059ed 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ On Windows PowerShell, activate the environment with: The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are: +The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged. + | Username | Password | Role | | --- | --- | --- | | `alice` | `secret123` | administrator | @@ -197,6 +199,9 @@ The public feed is available at: GET http://localhost:8000/api/public/feed ``` +When a request includes a valid bearer token, entries owned by that authenticated user include edit permission and show an inline **Edit** action in the feed. The update endpoint is `PUT /api/links/{link_id}` and rejects edits from other users. +Feed items also expose `is_owner`; it is `true` only for entries owned by the authenticated user and `false` for anonymous viewers or other users. + Admin plugin requests must include the administrator's token: ```sh @@ -214,3 +219,4 @@ Administrators can manage user privileges from the admin page. The `Administrato - **Port 8000 is occupied**: run uvicorn with another port and update the extension backend URL, for example `--port 8001`. - **Port 80 or 8080 is occupied**: change the host-side ports in `docker-compose.yml`. - **Stale development data**: stop the backend and remove `backend/data/linklog.db`, or run `docker compose down -v` for the container volume. +- **Schema migration issue**: inspect the database version with `sqlite3 backend/data/linklog.db 'PRAGMA user_version;'` and restart the application to apply pending migrations. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 1921d11..3c03253 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -241,3 +241,33 @@ git commit the whole lot ### Assistant outcome Ran the strict backend suite with 10 passing tests, staged all project changes, expanded `.gitignore` to exclude runtime avatar uploads, confirmed `.env`, caches, virtualenv files, bytecode, databases, and generated assets were not staged, and created the complete-project commit. + +### User +version the sql schema and make sure it can be upgraded in the future. + +### Assistant outcome +Replaced the unversioned schema bootstrap with ordered SQLite migrations using `PRAGMA user_version`. The current schema is migration 1, migrations are applied in order and are idempotent, and a database migration test verifies version tracking and table preservation. README migration guidance was added. The strict backend suite passed with 11 tests. + +### User +allow editing of individual links - add a edit button for the links owned by the authenticated user - otherwise do not show a link + +### Assistant outcome +Added owner-checked `PUT /api/links/{link_id}` updates and `can_edit` feed metadata based on the authenticated bearer token. The feed shows an inline Edit action only for the current user's links; anonymous viewers and other users receive no edit action. Added owner/permission regression coverage, bumped the feed asset to `v5`, and the strict backend suite passed with 12 tests. + +### User +Add authenticated link editing + +### Assistant outcome +Verified and strengthened the existing authenticated link-editing flow. Anonymous updates return `401`, owners can edit their links, non-owners receive `404`, and the browser only renders the Edit action when feed metadata has `can_edit: true`. Full strict backend validation passed with 12 tests. + +### User +Expose ownership if feed data + +### Assistant outcome +Added explicit `is_owner` metadata to each public feed item based on the authenticated bearer token. Anonymous and non-owner responses return `false`; the owner receives `true`. The feed Edit control now uses this field, and the strict backend suite passed with 12 tests. + +### User +Render edit action for owned links + +### Assistant outcome +Verified that the feed renders the Edit button only when `is_owner` is true, while the backend owner check remains enforced by `PUT /api/links/{link_id}`. The live Docker-served feed asset contains the owner-gated action, focused tests passed, and the full strict suite passed with 12 tests. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index eba4fe2..ddd53c0 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -43,6 +43,11 @@ 39. For the user filter create a dropdown of all users that are available on the server 40. If I select a user filter I want the appropriate page to be opened (e.g. /user/ for user of / -home- for all-users) 41. git commit the whole lot +42. version the sql schema and make sure it can be upgraded in the future. +43. allow editing of individual links - add a edit button for the links owned by the authenticated user - otherwise do not show a link +44. Add authenticated link editing +45. Expose ownership if feed data +46. Render edit action for owned links ## Future entries diff --git a/backend/app/api/links.py b/backend/app/api/links.py index 66e5e82..3c38d95 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -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() diff --git a/backend/app/api/public.py b/backend/app/api/public.py index f9e2795..4e03f6a 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -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 diff --git a/backend/app/database.py b/backend/app/database.py index ec48bef..3b48b8b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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( diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index 7070bf8..a6a9ab4 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -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( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5a0d6e3..a8ad47d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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(): diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py new file mode 100644 index 0000000..ef576e8 --- /dev/null +++ b/backend/tests/test_database.py @@ -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() \ No newline at end of file diff --git a/frontend/static/feed.js b/frontend/static/feed.js index 14a721b..5f26df2 100644 --- a/frontend/static/feed.js +++ b/frontend/static/feed.js @@ -3,6 +3,7 @@ const sortSelect = document.getElementById('sort-select'); const userFilter = document.getElementById('user-filter'); const cookieName = 'linklog-feed-preferences'; +const accessToken = localStorage.getItem('linklogAccessToken'); function createAvatar(user) { const avatar = document.createElement('div'); @@ -82,6 +83,15 @@ function renderFeed(items, showIdentity = true) { meta.className = 'meta'; meta.textContent = item.created_at || 'updated recently'; + if (item.is_owner) { + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'edit-button'; + editButton.textContent = 'Edit'; + editButton.addEventListener('click', () => showEditForm(article, item)); + article.appendChild(editButton); + } + if (showIdentity) { const header = document.createElement('div'); header.className = 'link-header'; @@ -100,12 +110,39 @@ function renderFeed(items, showIdentity = true) { }); } +function showEditForm(article, item) { + if (article.querySelector('.edit-form')) return; + const form = document.createElement('form'); + form.className = 'edit-form'; + form.innerHTML = ` + + + + + `; + form.elements.title.value = item.title || ''; + form.elements.url.value = item.url || ''; + form.elements.comment.value = item.comment || ''; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, { + method: 'PUT', + headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`}, + body: JSON.stringify(Object.fromEntries(new FormData(form))), + }); + if (response.ok) loadFeed(); + }); + article.appendChild(form); +} + async function loadFeed() { const routeUser = document.body.dataset.userFilter; const endpoint = routeUser ? `/api/public/feed/${encodeURIComponent(routeUser)}` : '/api/public/feed'; - const response = await fetch(endpoint); + const response = await fetch(endpoint, { + headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {}, + }); const data = await response.json(); let items = data || []; diff --git a/frontend/static/style.css b/frontend/static/style.css index 52e091a..91767e3 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -331,6 +331,23 @@ button:disabled { color: var(--red) !important; } +.edit-button { + min-width: 0; + margin-top: 14px; + padding: 8px 12px; + background: var(--surface-1); + border-color: var(--surface-2); + color: var(--text); +} + +.edit-form { + display: grid; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + .feed { padding-bottom: 40px; } diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index cd4af8d..eac53d9 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -54,6 +54,6 @@
- +