Skip to content

Commit bc05d55

Browse files
committed
feat: add file list
1 parent b8925ad commit bc05d55

17 files changed

Lines changed: 1241 additions & 205 deletions

File tree

backend/app/database.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@
9393
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
9494
);
9595
96+
CREATE TABLE IF NOT EXISTS media_references (
97+
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
98+
media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
99+
PRIMARY KEY (page_id, media_id)
100+
);
101+
102+
CREATE INDEX IF NOT EXISTS idx_media_refs_media ON media_references(media_id);
103+
96104
CREATE TABLE IF NOT EXISTS diagrams (
97105
id INTEGER PRIMARY KEY AUTOINCREMENT,
98106
page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
@@ -881,6 +889,21 @@ async def rebuild_all_backlinks(db):
881889
await db.commit()
882890

883891

892+
async def rebuild_all_media_refs(db):
893+
"""Rebuild media_references for all existing pages (one-time backfill)."""
894+
from app.services.media_ref import parse_and_update_media_refs
895+
896+
rows = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM media_references")
897+
if rows[0]["cnt"] > 0:
898+
return # Already populated
899+
900+
pages = await db.execute_fetchall("SELECT id, content_md FROM pages")
901+
for p in pages:
902+
await parse_and_update_media_refs(db, p["id"], p["content_md"])
903+
if pages:
904+
await db.commit()
905+
906+
884907
async def seed_welcome_page(db):
885908
"""Create a welcome/guide page on first launch when no pages exist."""
886909
# Ensure logo.png exists in media directory (independent of whether pages exist)
@@ -999,6 +1022,9 @@ async def init_db():
9991022
# Rebuild backlinks for existing pages
10001023
await rebuild_all_backlinks(db)
10011024

1025+
# Rebuild media references for existing pages
1026+
await rebuild_all_media_refs(db)
1027+
10021028
# Seed default templates
10031029
for t in DEFAULT_TEMPLATES:
10041030
existing = await db.execute_fetchall(

backend/app/routers/media.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from pathlib import Path
33
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
44
from fastapi.responses import FileResponse
5-
from app.schemas import MediaResponse
6-
from app.auth import get_current_user
5+
from app.schemas import MediaResponse, MediaListItem
6+
from app.auth import get_current_user, require_admin
77
from app.config import settings
88
from app.database import get_db
99

@@ -55,6 +55,80 @@ async def upload_media(
5555
}
5656

5757

58+
@router.get("", response_model=list[MediaListItem])
59+
async def list_media(user=Depends(get_current_user)):
60+
"""List all uploaded media with reference counts and linked pages.
61+
62+
Any authenticated user can browse the library (same permission level as
63+
uploading), but only admins can delete entries.
64+
"""
65+
db = await get_db()
66+
rows = await db.execute_fetchall(
67+
"""SELECT m.id, m.filename, m.original_name, m.mime_type, m.size_bytes,
68+
m.uploaded_by, m.uploaded_at,
69+
CASE WHEN u.display_name IS NOT NULL AND u.display_name != ''
70+
THEN u.display_name ELSE u.username END AS uploaded_by_name,
71+
(SELECT COUNT(*) FROM media_references r
72+
JOIN pages p ON p.id = r.page_id
73+
WHERE r.media_id = m.id AND p.deleted_at IS NULL) AS reference_count
74+
FROM media m
75+
LEFT JOIN users u ON u.id = m.uploaded_by
76+
ORDER BY m.uploaded_at DESC"""
77+
)
78+
79+
items: list[dict] = []
80+
for r in rows:
81+
item = dict(r)
82+
pages = await db.execute_fetchall(
83+
"""SELECT p.id, p.slug, p.title
84+
FROM media_references mr
85+
JOIN pages p ON p.id = mr.page_id
86+
WHERE mr.media_id = ? AND p.deleted_at IS NULL
87+
ORDER BY p.title""",
88+
(item["id"],),
89+
)
90+
item["referenced_pages"] = [dict(p) for p in pages]
91+
item["url"] = f"/api/media/{item['filename']}"
92+
items.append(item)
93+
return items
94+
95+
96+
@router.delete("/{media_id}", status_code=204)
97+
async def delete_media(media_id: int, user=Depends(require_admin)):
98+
"""Delete an uploaded media file. Refuses if any live page still references it."""
99+
db = await get_db()
100+
rows = await db.execute_fetchall(
101+
"SELECT filename, filepath FROM media WHERE id = ?", (media_id,)
102+
)
103+
if not rows:
104+
raise HTTPException(status_code=404, detail="Media not found")
105+
106+
ref_rows = await db.execute_fetchall(
107+
"""SELECT COUNT(*) AS cnt
108+
FROM media_references mr
109+
JOIN pages p ON p.id = mr.page_id
110+
WHERE mr.media_id = ? AND p.deleted_at IS NULL""",
111+
(media_id,),
112+
)
113+
if ref_rows[0]["cnt"] > 0:
114+
raise HTTPException(
115+
status_code=409,
116+
detail="Media is referenced by one or more pages and cannot be deleted",
117+
)
118+
119+
# Remove the file on disk. Guard against path traversal and missing files.
120+
media_dir = Path(settings.MEDIA_DIR).resolve()
121+
filepath = (media_dir / rows[0]["filename"]).resolve()
122+
if str(filepath).startswith(str(media_dir) + "/") and filepath.exists():
123+
try:
124+
filepath.unlink()
125+
except OSError:
126+
pass # Row deletion still proceeds; orphan file can be cleaned later.
127+
128+
await db.execute("DELETE FROM media WHERE id = ?", (media_id,))
129+
await db.commit()
130+
131+
58132
@router.get("/{filename}")
59133
async def get_media(filename: str):
60134
media_dir = Path(settings.MEDIA_DIR).resolve()

backend/app/routers/pages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from app.database import get_db
77
from app.services.search import rebuild_search_index, remove_from_search_index
88
from app.services.wikilink import parse_and_update_backlinks
9+
from app.services.media_ref import parse_and_update_media_refs
910
from app.routers.activity import log_activity
1011
from app.routers.versions import save_version
1112

@@ -132,6 +133,8 @@ async def create_page(body: PageCreate, user=Depends(get_current_user)):
132133
await rebuild_search_index(db, page_id, body.title, content)
133134
# Parse wikilinks → update backlinks
134135
await parse_and_update_backlinks(db, page_id, content)
136+
# Parse media URLs → update media_references
137+
await parse_and_update_media_refs(db, page_id, content)
135138
# Log activity
136139
await log_activity(db, user["id"], "created", "page", page_id, {"title": body.title, "slug": slug})
137140
await db.commit()
@@ -223,6 +226,8 @@ async def update_page(slug: str, body: PageUpdate, user=Depends(get_current_user
223226
await rebuild_search_index(db, current["id"], title, content)
224227
# Parse wikilinks → update backlinks
225228
await parse_and_update_backlinks(db, current["id"], content)
229+
# Parse media URLs → update media_references
230+
await parse_and_update_media_refs(db, current["id"], content)
226231
# Log activity
227232
if content_changed or title_changed:
228233
await log_activity(db, user["id"], "updated", "page", current["id"], {"title": title, "slug": slug})

backend/app/schemas.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,26 @@ class MediaResponse(BaseModel):
109109
url: str = ""
110110

111111

112+
class MediaReferencedPage(BaseModel):
113+
id: int
114+
slug: str
115+
title: str
116+
117+
118+
class MediaListItem(BaseModel):
119+
id: int
120+
filename: str
121+
original_name: str
122+
mime_type: str
123+
size_bytes: Optional[int] = None
124+
uploaded_by: Optional[int] = None
125+
uploaded_by_name: Optional[str] = None
126+
uploaded_at: Optional[str] = None
127+
url: str = ""
128+
reference_count: int = 0
129+
referenced_pages: list[MediaReferencedPage] = []
130+
131+
112132
# ── Diagrams ──
113133
class DiagramCreate(BaseModel):
114134
name: str

backend/app/services/media_ref.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import re
2+
3+
# Match /api/media/{filename} inside markdown image tags, plain URLs, or HTML attrs.
4+
# Filename is whatever isn't a quote, paren, whitespace, or closing bracket.
5+
MEDIA_URL_RE = re.compile(r'/api/media/([^\s"\'()<>\]]+)')
6+
7+
8+
def extract_media_filenames(content_md: str) -> set[str]:
9+
"""Extract all referenced media filenames from markdown content."""
10+
return {m.group(1) for m in MEDIA_URL_RE.finditer(content_md or "")}
11+
12+
13+
async def parse_and_update_media_refs(db, page_id: int, content_md: str):
14+
"""Parse media URLs from content and update the media_references table."""
15+
filenames = extract_media_filenames(content_md)
16+
17+
await db.execute("DELETE FROM media_references WHERE page_id = ?", (page_id,))
18+
19+
if not filenames:
20+
return
21+
22+
placeholders = ",".join("?" for _ in filenames)
23+
rows = await db.execute_fetchall(
24+
f"SELECT id FROM media WHERE filename IN ({placeholders})",
25+
list(filenames),
26+
)
27+
28+
for row in rows:
29+
await db.execute(
30+
"INSERT OR IGNORE INTO media_references (page_id, media_id) VALUES (?, ?)",
31+
(page_id, row["id"]),
32+
)

backend/tests/test_media.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import pytest
22

3+
from app.services.media_ref import extract_media_filenames
4+
5+
36
@pytest.mark.asyncio
47
async def test_media_upload_and_get(auth_client):
58
# Mock a file upload
69
file_content = b"fake image content"
710
files = {"file": ("test.png", file_content, "image/png")}
8-
11+
912
response = await auth_client.post("/api/media/upload", files=files)
1013
assert response.status_code == 201
1114
data = response.json()
@@ -16,3 +19,105 @@ async def test_media_upload_and_get(auth_client):
1619
response = await auth_client.get(f"/api/media/{filename}")
1720
assert response.status_code == 200
1821
assert response.content == file_content
22+
23+
24+
def test_extract_media_filenames_markdown_image():
25+
md = "![alt](/api/media/abc123.png)"
26+
assert extract_media_filenames(md) == {"abc123.png"}
27+
28+
29+
def test_extract_media_filenames_multiple_and_html():
30+
md = """
31+
![one](/api/media/one.png)
32+
<img src="/api/media/two.jpg" />
33+
Plain link: /api/media/three.pdf
34+
"""
35+
assert extract_media_filenames(md) == {"one.png", "two.jpg", "three.pdf"}
36+
37+
38+
def test_extract_media_filenames_empty():
39+
assert extract_media_filenames("") == set()
40+
assert extract_media_filenames("no media here") == set()
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_media_list_available_to_editors(auth_client):
45+
# Any authenticated user can browse the library to reuse assets.
46+
response = await auth_client.get("/api/media")
47+
assert response.status_code == 200
48+
assert isinstance(response.json(), list)
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_media_list_shows_upload_with_zero_refs(admin_client):
53+
files = {"file": ("zero.png", b"zero", "image/png")}
54+
up = await admin_client.post("/api/media/upload", files=files)
55+
assert up.status_code == 201
56+
uploaded = up.json()
57+
58+
response = await admin_client.get("/api/media")
59+
assert response.status_code == 200
60+
items = response.json()
61+
match = next((m for m in items if m["id"] == uploaded["id"]), None)
62+
assert match is not None
63+
assert match["reference_count"] == 0
64+
assert match["referenced_pages"] == []
65+
assert match["url"] == f"/api/media/{uploaded['filename']}"
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_media_reference_tracking_and_delete(admin_client):
70+
files = {"file": ("ref.png", b"ref", "image/png")}
71+
up = await admin_client.post("/api/media/upload", files=files)
72+
media = up.json()
73+
74+
# Create a page that references the uploaded media
75+
page_body = {
76+
"title": "References media",
77+
"content_md": f"Here is an image: ![x](/api/media/{media['filename']})",
78+
}
79+
page_resp = await admin_client.post("/api/pages", json=page_body)
80+
assert page_resp.status_code == 201
81+
page = page_resp.json()
82+
83+
# The list endpoint should now report 1 reference
84+
listing = await admin_client.get("/api/media")
85+
match = next(m for m in listing.json() if m["id"] == media["id"])
86+
assert match["reference_count"] == 1
87+
assert len(match["referenced_pages"]) == 1
88+
assert match["referenced_pages"][0]["slug"] == page["slug"]
89+
90+
# Delete should be blocked while referenced
91+
blocked = await admin_client.delete(f"/api/media/{media['id']}")
92+
assert blocked.status_code == 409
93+
94+
# Remove the reference by updating the page
95+
upd = await admin_client.put(
96+
f"/api/pages/{page['slug']}",
97+
json={"content_md": "No media anymore"},
98+
)
99+
assert upd.status_code == 200
100+
101+
listing2 = await admin_client.get("/api/media")
102+
match2 = next(m for m in listing2.json() if m["id"] == media["id"])
103+
assert match2["reference_count"] == 0
104+
105+
# Delete should now succeed
106+
ok = await admin_client.delete(f"/api/media/{media['id']}")
107+
assert ok.status_code == 204
108+
109+
# The media entry is gone
110+
listing3 = await admin_client.get("/api/media")
111+
assert all(m["id"] != media["id"] for m in listing3.json())
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_media_delete_non_admin_forbidden(auth_client):
116+
response = await auth_client.delete("/api/media/1")
117+
assert response.status_code == 403
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_media_delete_not_found(admin_client):
122+
response = await admin_client.delete("/api/media/99999999")
123+
assert response.status_code == 404

0 commit comments

Comments
 (0)