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.
2026-04-15 03:48:25 +08:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
|
|
|
|
|
|
|
|
|
|
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
|
|
|
|
|
return vi.fn().mockResolvedValue({
|
|
|
|
|
status,
|
|
|
|
|
ok: status >= 200 && status < 300,
|
|
|
|
|
blob: () => Promise.resolve(blob),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url')
|
|
|
|
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
|
|
|
|
vi.spyOn(document.body, 'appendChild').mockImplementation((el) => el)
|
|
|
|
|
vi.spyOn(document.body, 'removeChild').mockImplementation((el) => el)
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('assertRelativeUrl (URL guard)', () => {
|
|
|
|
|
it('rejects absolute http URLs', async () => {
|
|
|
|
|
await expect(downloadFile('https://evil.com/x')).rejects.toThrow('Refusing to fetch non-relative URL')
|
|
|
|
|
})
|
|
|
|
|
it('rejects protocol-relative URLs', async () => {
|
|
|
|
|
await expect(downloadFile('//evil.com/x')).rejects.toThrow('Refusing to fetch non-relative URL')
|
|
|
|
|
})
|
|
|
|
|
it('allows relative paths', async () => {
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
|
|
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
await expect(downloadFile('/trips/1/files/2/download')).resolves.toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('downloadFile', () => {
|
|
|
|
|
it('fetches with credentials:include and triggers anchor download', async () => {
|
|
|
|
|
const fetchMock = makeFetchMock(200)
|
|
|
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
|
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
await downloadFile('/uploads/files/test.pdf', 'test.pdf')
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('/uploads/files/test.pdf', { credentials: 'include' })
|
|
|
|
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
|
|
|
|
expect(clickSpy).toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
// Revoke happens after setTimeout(100)
|
|
|
|
|
vi.runAllTimers()
|
|
|
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sets download attribute to filename when provided', async () => {
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
|
|
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
await downloadFile('/uploads/files/report.pdf', 'report.pdf')
|
|
|
|
|
|
|
|
|
|
// Check anchor was created with download attribute
|
|
|
|
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
|
|
|
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
|
|
|
|
expect(anchor.download).toBe('report.pdf')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws on 401 response', async () => {
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(401))
|
|
|
|
|
await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
|
|
|
|
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('openFile', () => {
|
2026-04-23 16:06:56 +08:00
|
|
|
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
2026-04-23 16:06:56 +08:00
|
|
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
|
|
|
|
|
await openFile('/uploads/files/doc.pdf')
|
|
|
|
|
|
|
|
|
|
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
|
|
|
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
2026-04-23 16:06:56 +08:00
|
|
|
// Must NOT call window.open — that path returns null when noreferrer is
|
|
|
|
|
// set, which previously caused the file to also open in the current tab.
|
|
|
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
// The anchor used to open the new tab must be target=_blank, must NOT
|
|
|
|
|
// carry a `download` attribute (otherwise it would download in-page
|
|
|
|
|
// instead of opening), and must use rel=noopener noreferrer.
|
|
|
|
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
|
|
|
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
|
|
|
|
expect(anchor.target).toBe('_blank')
|
|
|
|
|
expect(anchor.rel).toBe('noopener noreferrer')
|
|
|
|
|
expect(anchor.hasAttribute('download')).toBe(false)
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
|
|
|
|
|
// Revoke happens after 30s timeout
|
|
|
|
|
vi.runAllTimers()
|
|
|
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws on 401 response', async () => {
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(401, new Blob([], { type: 'application/pdf' })))
|
|
|
|
|
await expect(openFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
|
|
|
|
|
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
|
|
|
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
|
|
|
|
|
// Must NOT open inline — download anchor clicked instead
|
|
|
|
|
expect(openSpy).not.toHaveBeenCalled()
|
2026-04-23 16:06:56 +08:00
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
|
|
|
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
|
|
|
|
expect(anchor.download).toBe('malicious.html')
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('forces download for SVG MIME type', async () => {
|
|
|
|
|
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
2026-04-23 16:06:56 +08:00
|
|
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
await openFile('/uploads/files/malicious.svg')
|
|
|
|
|
|
2026-04-23 16:06:56 +08:00
|
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
|
|
|
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
|
|
|
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
|
|
|
|
// Simulate iOS PWA (Add-to-Home-Screen) context
|
|
|
|
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
|
|
|
|
|
|
|
|
|
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
|
|
|
|
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
|
|
|
|
// read the in-WebView blob URL.
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1)
|
|
|
|
|
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
|
|
|
|
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
|
|
|
|
expect(anchor.target).toBe('')
|
|
|
|
|
expect(anchor.download).toBe('doc.pdf')
|
|
|
|
|
} finally {
|
|
|
|
|
// Clean up the non-standard iOS-only property we forced above.
|
|
|
|
|
delete (navigator as any).standalone
|
|
|
|
|
}
|
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.
2026-04-15 03:48:25 +08:00
|
|
|
})
|
|
|
|
|
})
|