Skip to content

Commit 560d0f2

Browse files
PttCodingManclaude
andcommitted
fix: media_refs consistency, list_media N+1, path guard hardening
- versions.py: revert path was updating backlinks but not media_references, leaving the deletion guard out of sync with actual references - database.py: rebuild_all_media_refs guard now uses PRAGMA user_version so the one-time backfill runs on wikis that already had media rows - media.py: list_media replaces per-item JOIN with a single grouped query - media.py: path traversal guard uses Path.is_relative_to instead of a string suffix check - MediaPickerModal / Admin: strip []() from filename before embedding into generated markdown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0d12f12 commit 560d0f2

5 files changed

Lines changed: 36 additions & 22 deletions

File tree

backend/app/database.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -890,18 +890,24 @@ async def rebuild_all_backlinks(db):
890890

891891

892892
async def rebuild_all_media_refs(db):
893-
"""Rebuild media_references for all existing pages (one-time backfill)."""
893+
"""Rebuild media_references for all existing pages (one-time backfill).
894+
895+
Tracked via PRAGMA user_version bit 0x1. The previous COUNT(*)>0 guard
896+
skipped the scan as soon as any row existed, which meant pages created
897+
before the feature shipped never got their refs backfilled on a wiki
898+
that had already added media after the first deploy.
899+
"""
894900
from app.services.media_ref import parse_and_update_media_refs
895901

896-
rows = await db.execute_fetchall("SELECT COUNT(*) as cnt FROM media_references")
897-
if rows[0]["cnt"] > 0:
898-
return # Already populated
902+
rows = await db.execute_fetchall("PRAGMA user_version")
903+
current = rows[0]["user_version"] if rows else 0
904+
if current & 0x1:
905+
return
899906

900907
pages = await db.execute_fetchall("SELECT id, content_md FROM pages")
901908
for p in pages:
902909
await parse_and_update_media_refs(db, p["id"], p["content_md"])
903-
if pages:
904-
await db.commit()
910+
await db.execute(f"PRAGMA user_version = {current | 0x1}")
905911

906912

907913
async def seed_welcome_page(db):

backend/app/routers/media.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,24 @@ async def list_media(user=Depends(get_current_user)):
7676
ORDER BY m.uploaded_at DESC"""
7777
)
7878

79+
# Single grouped query for referenced pages across all media, keyed by media_id.
80+
ref_rows = await db.execute_fetchall(
81+
"""SELECT mr.media_id, p.id, p.slug, p.title
82+
FROM media_references mr
83+
JOIN pages p ON p.id = mr.page_id
84+
WHERE p.deleted_at IS NULL
85+
ORDER BY p.title"""
86+
)
87+
refs_by_media: dict[int, list[dict]] = {}
88+
for r in ref_rows:
89+
refs_by_media.setdefault(r["media_id"], []).append(
90+
{"id": r["id"], "slug": r["slug"], "title": r["title"]}
91+
)
92+
7993
items: list[dict] = []
8094
for r in rows:
8195
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]
96+
item["referenced_pages"] = refs_by_media.get(item["id"], [])
9197
item["url"] = f"/api/media/{item['filename']}"
9298
items.append(item)
9399
return items
@@ -119,7 +125,7 @@ async def delete_media(media_id: int, user=Depends(require_admin)):
119125
# Remove the file on disk. Guard against path traversal and missing files.
120126
media_dir = Path(settings.MEDIA_DIR).resolve()
121127
filepath = (media_dir / rows[0]["filename"]).resolve()
122-
if str(filepath).startswith(str(media_dir) + "/") and filepath.exists():
128+
if filepath.is_relative_to(media_dir) and filepath.exists():
123129
try:
124130
filepath.unlink()
125131
except OSError:
@@ -135,7 +141,7 @@ async def get_media(filename: str):
135141
filepath = (media_dir / filename).resolve()
136142

137143
# Prevent path traversal: resolved path must be inside MEDIA_DIR
138-
if not str(filepath).startswith(str(media_dir) + "/"):
144+
if not filepath.is_relative_to(media_dir):
139145
raise HTTPException(status_code=400, detail="Invalid filename")
140146

141147
if not filepath.exists():

backend/app/routers/versions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from app.database import get_db
55
from app.services.search import rebuild_search_index
66
from app.services.wikilink import parse_and_update_backlinks
7+
from app.services.media_ref import parse_and_update_media_refs
78
from app.routers.activity import log_activity
89

910
router = APIRouter(prefix="/api/pages", tags=["versions"])
@@ -149,6 +150,7 @@ async def revert_to_version(slug: str, num: int, user=Depends(get_current_user))
149150
)
150151
await rebuild_search_index(db, current["id"], version[0]["title"], version[0]["content_md"])
151152
await parse_and_update_backlinks(db, current["id"], version[0]["content_md"])
153+
await parse_and_update_media_refs(db, current["id"], version[0]["content_md"])
152154
await log_activity(
153155
db, user["id"], "reverted", "page", current["id"],
154156
{"title": version[0]["title"], "slug": slug, "to_version": num},

frontend/src/components/Editor/MediaPickerModal.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,8 @@ export default function MediaPickerModal({ open, onClose, onInsert }) {
5959

6060
const buildMarkdown = (item) => {
6161
const isImage = item.mime_type?.startsWith('image/')
62-
return isImage
63-
? `![${item.original_name}](${item.url})`
64-
: `[${item.original_name}](${item.url})`
62+
const label = (item.original_name || '').replace(/[[\]()]/g, '')
63+
return isImage ? `![${label}](${item.url})` : `[${label}](${item.url})`
6564
}
6665

6766
const q = query.trim().toLowerCase()

frontend/src/pages/Admin.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,10 @@ function MediaLibrarySection() {
269269
}
270270

271271
const copyMarkdown = async (item) => {
272+
const label = (item.original_name || '').replace(/[[\]()]/g, '')
272273
const snippet = item.mime_type?.startsWith('image/')
273-
? `![${item.original_name}](${item.url})`
274-
: `[${item.original_name}](${item.url})`
274+
? `![${label}](${item.url})`
275+
: `[${label}](${item.url})`
275276
try {
276277
await navigator.clipboard.writeText(snippet)
277278
setCopied(item.id)

0 commit comments

Comments
 (0)