Edit of links and database versioning

This commit is contained in:
Olaf
2026-08-24 19:39:21 +02:00
parent ff249ec911
commit 28a01175f7
12 changed files with 239 additions and 8 deletions
+6
View File
@@ -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 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 | | Username | Password | Role |
| --- | --- | --- | | --- | --- | --- |
| `alice` | `secret123` | administrator | | `alice` | `secret123` | administrator |
@@ -197,6 +199,9 @@ The public feed is available at:
GET http://localhost:8000/api/public/feed 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: Admin plugin requests must include the administrator's token:
```sh ```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 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`. - **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. - **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.
+30
View File
@@ -241,3 +241,33 @@ git commit the whole lot
### Assistant outcome ### 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. 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.
+5
View File
@@ -43,6 +43,11 @@
39. For the user filter create a dropdown of all users that are available on the server 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) 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 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 ## Future entries
+25 -1
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Header, HTTPException, status from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel 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.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token from backend.app.services.token_service import validate_token
@@ -15,6 +15,12 @@ class LinkCreate(BaseModel):
timestamp: str | None = None timestamp: str | None = None
class LinkUpdate(BaseModel):
title: str
url: str
comment: str = ''
@router.post('/links', status_code=status.HTTP_201_CREATED) @router.post('/links', status_code=status.HTTP_201_CREATED)
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)): def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)):
if not authorization or not authorization.startswith('Bearer '): if not authorization or not authorization.startswith('Bearer '):
@@ -29,6 +35,24 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
return record 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') @router.get('/links')
def list_links(): def list_links():
return list_public_links() return list_public_links()
+15 -2
View File
@@ -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.link_service import list_public_links, list_public_users
from backend.app.services.token_service import validate_token
router = APIRouter() router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False)
@router.get('/users') @router.get('/users')
@@ -12,7 +15,15 @@ def public_users():
@router.get('/feed') @router.get('/feed')
@router.get('/feed/{username}') @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) items = list_public_links(username)
return [ return [
{ {
@@ -25,6 +36,8 @@ def public_feed(username: str | None = None):
'avatar_url': item['avatar_url'], 'avatar_url': item['avatar_url'],
'bio': item['bio'], 'bio': item['bio'],
}, },
'is_owner': item['user_id'] == current_user_id,
'can_edit': item['user_id'] == current_user_id,
'created_at': item['created_at'], 'created_at': item['created_at'],
} }
for item in items for item in items
+19 -3
View File
@@ -13,7 +13,8 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest() return sha256(password.encode('utf-8')).hexdigest()
SCHEMA = ''' MIGRATIONS = [
(1, '''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -70,7 +71,8 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
UNIQUE(user_id, plugin_name), UNIQUE(user_id, plugin_name),
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
''' '''),
]
def get_connection() -> sqlite3.Connection: def get_connection() -> sqlite3.Connection:
@@ -80,9 +82,23 @@ def get_connection() -> sqlite3.Connection:
return conn 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: def init_db() -> None:
with get_connection() as conn: with get_connection() as conn:
conn.executescript(SCHEMA) apply_migrations(conn)
alice_hash = hash_password('secret123') alice_hash = hash_password('secret123')
bob_hash = hash_password('secret123') bob_hash = hash_password('secret123')
conn.execute( conn.execute(
+18
View File
@@ -57,6 +57,24 @@ def list_public_links(username: str | None = None):
return [dict(row) for row in rows] 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(): def list_public_users():
with get_connection() as conn: with get_connection() as conn:
rows = conn.execute( rows = conn.execute(
+43
View File
@@ -111,6 +111,47 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
assert 'alice' in users_response.json() 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(): def test_logout_revokes_token_and_admin_can_list_plugins():
headers = login_headers() headers = login_headers()
token = headers['Authorization'].removeprefix('Bearer ') 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-auth-notice" class="auth-notice hidden"' in admin_page
assert 'id="admin-login-button" class="login-button" href="/login"' 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 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(): def test_link_submission_posts_to_enabled_mastodon_plugin():
+22
View File
@@ -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()
+38 -1
View File
@@ -3,6 +3,7 @@ const sortSelect = document.getElementById('sort-select');
const userFilter = document.getElementById('user-filter'); const userFilter = document.getElementById('user-filter');
const cookieName = 'linklog-feed-preferences'; const cookieName = 'linklog-feed-preferences';
const accessToken = localStorage.getItem('linklogAccessToken');
function createAvatar(user) { function createAvatar(user) {
const avatar = document.createElement('div'); const avatar = document.createElement('div');
@@ -82,6 +83,15 @@ function renderFeed(items, showIdentity = true) {
meta.className = 'meta'; meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently'; 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) { if (showIdentity) {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'link-header'; 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 = `
<label>Title <input name="title" value=""></label>
<label>URL <input name="url" type="url" value=""></label>
<label>Comment <textarea name="comment"></textarea></label>
<button type="submit">Save changes</button>
`;
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() { async function loadFeed() {
const routeUser = document.body.dataset.userFilter; const routeUser = document.body.dataset.userFilter;
const endpoint = routeUser const endpoint = routeUser
? `/api/public/feed/${encodeURIComponent(routeUser)}` ? `/api/public/feed/${encodeURIComponent(routeUser)}`
: '/api/public/feed'; : '/api/public/feed';
const response = await fetch(endpoint); const response = await fetch(endpoint, {
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
});
const data = await response.json(); const data = await response.json();
let items = data || []; let items = data || [];
+17
View File
@@ -331,6 +331,23 @@ button:disabled {
color: var(--red) !important; 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 { .feed {
padding-bottom: 40px; padding-bottom: 40px;
} }
+1 -1
View File
@@ -54,6 +54,6 @@
<section id="feed" class="feed" aria-live="polite"></section> <section id="feed" class="feed" aria-live="polite"></section>
</main> </main>
<script src="/static/feed.js?v=4"></script> <script src="/static/feed.js?v=5"></script>
</body> </body>
</html> </html>