feat(05-09): authenticated document preview via fetch + Blob URL

- 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
This commit is contained in:
curo1305
2026-05-30 11:18:01 +02:00
parent 6d094d17f0
commit 4a42ccee5a
3 changed files with 131 additions and 6 deletions
+43
View File
@@ -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() {