fix(pwa): fix offline session redirect and file download auth (#505 #541)

**#541 — File downloads broken in PWA standalone mode**
Replace getAuthUrl + window.open pattern with blob-based fetch using
credentials:include. The old approach minted a 60s single-use ephemeral
token then called window.open, which handed the URL to the system browser
on Android/iOS — losing the PWA cookie jar and producing "invalid or
expired token". The new approach fetches the file directly inside the
PWA WebView as a blob URL, so no auth handoff occurs.

New helper client/src/utils/fileDownload.ts with downloadFile and openFile.
Updated FileManager, ReservationsPanel, ReservationModal, PlaceInspector,
CollabNotes.

Security hardening in fileDownload.ts:
- assertRelativeUrl() guard prevents credentials being sent to external hosts
- openFile() checks blob.type against a safe-inline allowlist; HTML, SVG and
  other script-capable MIME types are forced to download instead of being
  opened inline, preventing same-origin XSS via blob URLs
- resp.ok check covers all non-2xx responses, not just 401

**#505 — PWA offline session lost on reload**
Wrap authStore with Zustand persist middleware, serializing only
{user, isAuthenticated} to localStorage key trek_auth_snapshot.
maps_api_key is intentionally excluded from the snapshot.

On cold start with no network: persist hydrates isAuthenticated:true,
App.tsx clears isLoading and calls loadUser({silent:true}), ProtectedRoute
renders the dashboard immediately. The network error from loadUser leaves
isAuthenticated intact so no login redirect occurs.

On 401 or logout: store state is cleared, persist writes
{isAuthenticated:false} — stale snapshot does not grant offline access
after session expiry.
This commit is contained in:
jubnl
2026-04-14 21:48:25 +02:00
parent 1268d3e7b1
commit 8c7567faf3
10 changed files with 314 additions and 26 deletions
+81
View File
@@ -0,0 +1,81 @@
// MIME types safe to open inline (will not execute script in any browser).
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
// download so a maliciously-named upload cannot run code in the TREK origin.
const SAFE_INLINE_TYPES = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
])
/**
* Asserts that `url` is a relative same-origin path so that
* `credentials: 'include'` cannot be used to send the session cookie to an
* external host (e.g. if an attacker somehow controls the `url` value).
*/
function assertRelativeUrl(url: string): void {
if (!url.startsWith('/') || url.startsWith('//') || url.startsWith('/\\')) {
throw new Error(`Refusing to fetch non-relative URL: ${url}`)
}
}
function triggerAnchorDownload(blobUrl: string, filename?: string): void {
const a = document.createElement('a')
a.href = blobUrl
if (filename) a.download = filename
document.body.appendChild(a)
a.click()
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
}
/**
* Fetches a protected file using cookie auth (credentials: include) and
* triggers a browser download. Works inside PWA standalone mode because the
* fetch stays in the PWA's WebView rather than handing off to the system
* browser (which would lose the session cookie).
*/
export async function downloadFile(url: string, filename?: string): Promise<void> {
assertRelativeUrl(url)
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
triggerAnchorDownload(blobUrl, filename)
}
/**
* Fetches a protected file using cookie auth and opens it in a new tab as a
* blob URL. The blob URL is same-origin to the PWA context so no system
* browser handoff occurs, fixing the auth error in PWA standalone mode.
*
* Only PDFs and raster images are opened inline. All other MIME types
* (including text/html and image/svg+xml which can execute script) are forced
* to download so that an uploaded file cannot run code in the TREK origin.
*
* Falls back to a download trigger if the popup is blocked.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
assertRelativeUrl(url)
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
// Force download for MIME types that can execute script when rendered inline
if (!SAFE_INLINE_TYPES.has(blob.type)) {
triggerAnchorDownload(blobUrl, filename)
return
}
const win = window.open(blobUrl, '_blank', 'noreferrer')
if (win) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
} else {
// Popup blocked — fall back to download
triggerAnchorDownload(blobUrl, filename)
}
}