From 4a42ccee5a6bb47ef484c0a899fa6bfcaec136ff Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 30 May 2026 11:18:01 +0200 Subject: [PATCH] feat(05-09): authenticated document preview via fetch + Blob URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fetchDocumentContent() to client.js: fetch with Bearer auth, 401 refresh retry pattern, returns raw Response (not parsed JSON) for blob() calls - Replace iframe :src=proxyUrl (unauthenticated) in DocumentPreviewModal.vue with authenticated fetch → blob → URL.createObjectURL; loading/error states; URL.revokeObjectURL on unmount to prevent memory leaks - Replace window.open(rawUrl) in DocumentView.vue openPdf() with fetchDocumentContent → blob → objectUrl → window.open; 60s auto-revoke - Frontend build exits 0 with zero errors - Closes T-05-09-04: no persistent unauthenticated content exposure --- frontend/src/api/client.js | 43 +++++++++++ .../documents/DocumentPreviewModal.vue | 73 ++++++++++++++++++- frontend/src/views/DocumentView.vue | 21 +++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 8a37128..aa7da8c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -365,6 +365,49 @@ export function getDocumentContentUrl(docId) { return `/api/documents/${docId}/content` } +/** + * Fetch document content bytes with authentication, returning the raw Response. + * + * Unlike request(), this function does NOT call res.json() — it returns the raw + * Response so callers can call .blob() to build an object URL for iframe preview + * or window.open() without an unauthenticated src= attribute. + * + * On 401: attempts one token refresh via authStore.refresh() then retries. + * On refresh failure: clears auth state and throws 'Session expired'. + * + * Security: closes the unauthenticated content-access gap where an iframe src= + * or window.open() with a raw /content URL would bypass the Bearer auth check + * in cases where the browser does not send the cookie (cross-origin, incognito). + * See plan 05-09 trust boundary: frontend→/api/documents/{id}/content. + */ +export async function fetchDocumentContent(docId, options = {}) { + 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/documents/${docId}/content`, { + headers, + credentials: 'include', + }) + + if (res.status === 401 && !options._retry) { + try { + await authStore.refresh() + return fetchDocumentContent(docId, { _retry: true }) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + + return res +} + // ── Cloud Storage ───────────────────────────────────────────────────────────── export function listCloudConnections() { diff --git a/frontend/src/components/documents/DocumentPreviewModal.vue b/frontend/src/components/documents/DocumentPreviewModal.vue index 50128aa..85c7dc9 100644 --- a/frontend/src/components/documents/DocumentPreviewModal.vue +++ b/frontend/src/components/documents/DocumentPreviewModal.vue @@ -23,10 +23,37 @@ -
+
+ +
+
+ + + + + Loading preview… +
+
+ + +
+
+

Preview failed

+

{{ loadError }}

+
+
+ +
@@ -34,7 +61,8 @@ diff --git a/frontend/src/views/DocumentView.vue b/frontend/src/views/DocumentView.vue index 62c9baa..8e1fe80 100644 --- a/frontend/src/views/DocumentView.vue +++ b/frontend/src/views/DocumentView.vue @@ -119,6 +119,7 @@ import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.v import { useDocumentsStore } from '../stores/documents.js' import { useTopicsStore } from '../stores/topics.js' import * as api from '../api/client.js' +import { fetchDocumentContent } from '../api/client.js' const route = useRoute() const router = useRouter() @@ -157,11 +158,27 @@ onMounted(async () => { } }) -function openPdf() { +async function openPdf() { if (pdfOpenMode.value === 'in_app') { showPreviewModal.value = true } else { - window.open(api.getDocumentContentUrl(doc.value.id), '_blank') + // Fetch with Authorization header → blob → object URL → window.open + // This closes the unauthenticated access gap: window.open(rawUrl) would bypass + // Bearer auth for cloud documents (plan 05-09 trust boundary). + try { + const res = await fetchDocumentContent(doc.value.id) + if (!res.ok) { + console.error('Failed to open document:', res.status) + return + } + const blob = await res.blob() + const objectUrl = URL.createObjectURL(blob) + window.open(objectUrl, '_blank') + // Revoke after a delay to allow the new tab to load the content + setTimeout(() => URL.revokeObjectURL(objectUrl), 60000) + } catch (err) { + console.error('Failed to open document:', err) + } } }