From a3ad36cc82480871178f485b6c43e171f9de5383 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:24:50 +0200 Subject: [PATCH 01/14] fix(06.2): CR-01 event-type filter uses prefix LIKE match instead of exact equality --- backend/api/audit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/audit.py b/backend/api/audit.py index f3edb6c..040b163 100644 --- a/backend/api/audit.py +++ b/backend/api/audit.py @@ -117,7 +117,7 @@ def _build_filtered_query( if user_id is not None: q = q.where(AuditLog.user_id == user_id) if event_type is not None: - q = q.where(AuditLog.event_type == event_type) + q = q.where(AuditLog.event_type.like(f"{event_type}%")) return q @@ -156,7 +156,7 @@ def _build_filtered_query_with_handles( if user_uuid is not None: q = q.where(AuditLog.user_id == user_uuid) if event_type is not None: - q = q.where(AuditLog.event_type == event_type) + q = q.where(AuditLog.event_type.like(f"{event_type}%")) return q @@ -281,7 +281,7 @@ async def list_audit_log( if user_uuid is not None: count_q = count_q.where(AuditLog.user_id == user_uuid) if event_type is not None: - count_q = count_q.where(AuditLog.event_type == event_type) + count_q = count_q.where(AuditLog.event_type.like(f"{event_type}%")) count_result = await session.execute(count_q) total = count_result.scalar_one() From 50859bb430ade4b6d2fd21e4b3e28abdc88be8fc Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:25:06 +0200 Subject: [PATCH 02/14] fix(06.2): CR-02 add MinIOBackend guard in download_daily_export before accessing _client --- backend/api/audit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/api/audit.py b/backend/api/audit.py index 040b163..a2aced8 100644 --- a/backend/api/audit.py +++ b/backend/api/audit.py @@ -217,6 +217,8 @@ async def download_daily_export( raise HTTPException(status_code=404, detail="Invalid date format") backend = get_storage_backend() + if not isinstance(backend, MinIOBackend): + raise HTTPException(status_code=404, detail="Export not found") key = f"audit-logs/{date}.csv" def _get() -> bytes: From 792d4639d1081ecd52654471168226847fe1528c Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:25:29 +0200 Subject: [PATCH 03/14] fix(06.2): CR-03 serialize metadata_ with json.dumps in CSV export instead of Python repr --- backend/api/audit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/api/audit.py b/backend/api/audit.py index a2aced8..0fe714b 100644 --- a/backend/api/audit.py +++ b/backend/api/audit.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio import csv import io +import json import re import uuid from datetime import datetime @@ -371,7 +372,9 @@ async def export_audit_log( writer.writeheader() for row in rows: entry, user_handle_val, actor_handle_val = row[0], row[1], row[2] - writer.writerow(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)) + record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val) + record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else "" + writer.writerow(record) return StreamingResponse( iter([output.getvalue()]), From 3fa7e8b8662c066561e71f8431caf4e65e0a01e5 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:26:05 +0200 Subject: [PATCH 04/14] fix(06.2): CR-04 WR-05 audit export functions use 401-refresh-retry and safe URL.revokeObjectURL --- frontend/src/api/client.js | 54 ++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6368be9..9bc6339 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -395,7 +395,7 @@ export function adminListAuditLog({ start, end, user_handle, event_type, page = * the endpoint can authenticate the request (D-13, T-06.2-04-03). * Must NOT call res.json() — CSV is text/csv (Pitfall 5). */ -export async function adminExportAuditLogCsv(params = {}) { +export async function adminExportAuditLogCsv(params = {}, _retry = false) { const { useAuthStore } = await import('../stores/auth.js') const authStore = useAuthStore() @@ -414,6 +414,18 @@ export async function adminExportAuditLogCsv(params = {}) { headers, credentials: 'include', }) + + if (res.status === 401 && !_retry) { + try { + await authStore.refresh() + return adminExportAuditLogCsv(params, true) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + if (!res.ok) throw new Error(`Export failed: ${res.status}`) const text = await res.text() @@ -422,8 +434,10 @@ export async function adminExportAuditLogCsv(params = {}) { const a = document.createElement('a') a.href = url a.download = 'audit-export.csv' + document.body.appendChild(a) a.click() - URL.revokeObjectURL(url) + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) } /** @@ -431,22 +445,10 @@ export async function adminExportAuditLogCsv(params = {}) { * * Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] } * Items are sorted descending by date. + * Routes through request() which has built-in 401-refresh-retry logic. */ -export async function adminListDailyExports() { - const { useAuthStore } = await import('../stores/auth.js') - const authStore = useAuthStore() - - const headers = {} - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` - } - - const res = await fetch('/api/admin/audit-log/daily-exports', { - headers, - credentials: 'include', - }) - if (!res.ok) throw new Error(`Failed to list daily exports: ${res.status}`) - return res.json() +export function adminListDailyExports() { + return request('/api/admin/audit-log/daily-exports') } /** @@ -457,7 +459,7 @@ export async function adminListDailyExports() { * * @param {string} date — YYYY-MM-DD format date string */ -export async function adminDownloadDailyExport(date) { +export async function adminDownloadDailyExport(date, _retry = false) { const { useAuthStore } = await import('../stores/auth.js') const authStore = useAuthStore() @@ -470,6 +472,18 @@ export async function adminDownloadDailyExport(date) { headers, credentials: 'include', }) + + if (res.status === 401 && !_retry) { + try { + await authStore.refresh() + return adminDownloadDailyExport(date, true) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + if (!res.ok) throw new Error(`Download failed: ${res.status}`) const text = await res.text() @@ -478,8 +492,10 @@ export async function adminDownloadDailyExport(date) { const a = document.createElement('a') a.href = url a.download = `audit-${date}.csv` + document.body.appendChild(a) a.click() - URL.revokeObjectURL(url) + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) } // ── Document content proxy URL ──────────────────────────────────────────────── From 653cb3a98bdacdd84420314e4af7189a4d5a80c7 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:26:24 +0200 Subject: [PATCH 05/14] =?UTF-8?q?fix(06.2):=20CR-05=20remove=20UUID=20dash?= =?UTF-8?q?-stripping=20in=20quota=20SQL=20=E2=80=94=20PostgreSQL=20expect?= =?UTF-8?q?s=20dashed=20UUID=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/documents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/api/documents.py b/backend/api/documents.py index 40cb68e..97ed572 100644 --- a/backend/api/documents.py +++ b/backend/api/documents.py @@ -345,7 +345,7 @@ async def confirm_upload( " AND (used_bytes + :delta) <= limit_bytes " "RETURNING used_bytes, limit_bytes" ), - {"delta": size, "uid": str(doc.user_id).replace("-", "")}, + {"delta": size, "uid": str(doc.user_id)}, ) row = result.fetchone() @@ -353,7 +353,7 @@ async def confirm_upload( # Quota exceeded — fetch current quota state for the 413 body quota_result = await session.execute( text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"), - {"uid": str(doc.user_id).replace("-", "")}, + {"uid": str(doc.user_id)}, ) q = quota_result.fetchone() # Delete the pending Document row and best-effort remove the MinIO object From 1a34209bb026f0bfb8a18abeb907f6aa7c937877 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:26:46 +0200 Subject: [PATCH 06/14] fix(06.2): CR-06 RFC 5987-encode Content-Disposition filename to prevent header injection --- backend/api/documents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/api/documents.py b/backend/api/documents.py index 97ed572..907c648 100644 --- a/backend/api/documents.py +++ b/backend/api/documents.py @@ -21,6 +21,7 @@ to all handlers. The doc.user_id=None guard in /confirm is a Wave 2 placeholder. """ from __future__ import annotations +import urllib.parse import uuid from pathlib import Path from typing import Optional @@ -786,9 +787,10 @@ async def stream_document_content( ) from exc file_size = len(file_bytes) + safe_name = urllib.parse.quote(doc.filename, safe='') headers = { "content-type": doc.content_type, - "content-disposition": f'inline; filename="{doc.filename}"', + "content-disposition": f"inline; filename*=UTF-8''{safe_name}", "accept-ranges": "bytes", "content-length": str(file_size), } From 1f2cec9ac31f57a5429cccec3ffd215e353638c2 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:27:08 +0200 Subject: [PATCH 07/14] fix(06.2): CR-07 add audit log entry for PATCH /shares/{share_id} permission change --- backend/api/shares.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/api/shares.py b/backend/api/shares.py index 6067135..e75c87d 100644 --- a/backend/api/shares.py +++ b/backend/api/shares.py @@ -247,6 +247,7 @@ async def list_shared_with_me( async def update_share_permission( share_id: str, body: SharePermissionPatch, + request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: @@ -265,6 +266,16 @@ async def update_share_permission( raise HTTPException(status_code=404, detail="Share not found") share.permission = body.permission + + await write_audit_log( + session=session, + event_type="share.permission_changed", + user_id=current_user.id, + actor_id=current_user.id, + resource_id=share.document_id, + ip_address=_ip(request), + metadata_={"share_id": str(share.id), "new_permission": body.permission}, + ) await session.commit() return {"id": str(share.id), "permission": share.permission} From 2542c81602ed9477c6a65f9d205f3b4a08c7ac3a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 1 Jun 2026 14:27:47 +0200 Subject: [PATCH 08/14] fix(06.2): WR-03 WR-04 fix pagination off-by-one and surface daily exports load errors --- frontend/src/components/admin/AuditLogTab.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/admin/AuditLogTab.vue b/frontend/src/components/admin/AuditLogTab.vue index 95c9d59..c0cac67 100644 --- a/frontend/src/components/admin/AuditLogTab.vue +++ b/frontend/src/components/admin/AuditLogTab.vue @@ -134,7 +134,7 @@ Page {{ page }}