mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
**#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:
+9
-1
@@ -93,7 +93,15 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||||
loadUser()
|
// If the persist snapshot already has an authenticated user, validate
|
||||||
|
// silently so the PWA shell renders immediately without a spinner.
|
||||||
|
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||||
|
if (alreadyAuthenticated) {
|
||||||
|
useAuthStore.setState({ isLoading: false })
|
||||||
|
loadUser({ silent: true })
|
||||||
|
} else {
|
||||||
|
loadUser()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import remarkBreaks from 'remark-breaks'
|
|||||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
@@ -111,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
|||||||
const isPdf = file.mime_type === 'application/pdf'
|
const isPdf = file.mime_type === 'application/pdf'
|
||||||
const isTxt = file.mime_type?.startsWith('text/')
|
const isTxt = file.mime_type?.startsWith('text/')
|
||||||
|
|
||||||
const openInNewTab = async () => {
|
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||||
const u = await getAuthUrl(rawUrl, 'download')
|
|
||||||
window.open(u, '_blank', 'noreferrer')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { downloadFile, openFile } from '../../utils/fileDownload'
|
||||||
|
|
||||||
function isImage(mimeType) {
|
function isImage(mimeType) {
|
||||||
if (!mimeType) return false
|
if (!mimeType) return false
|
||||||
@@ -30,16 +31,8 @@ function formatSize(bytes) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerDownload(url: string, filename: string) {
|
function triggerDownload(url: string, filename: string) {
|
||||||
const authUrl = await getAuthUrl(url, 'download')
|
downloadFile(url, filename).catch(() => {})
|
||||||
const res = await fetch(authUrl)
|
|
||||||
const blob = await res.blob()
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = URL.createObjectURL(blob)
|
|
||||||
a.download = filename
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateWithLocale(dateStr, locale) {
|
function formatDateWithLocale(dateStr, locale) {
|
||||||
@@ -120,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
onClick={() => openFile(file.url).catch(() => {})}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||||
title={t('files.openTab')}>
|
title={t('files.openTab')}>
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
@@ -750,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
|
onClick={() => openFile(previewFile.url).catch(() => {})}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||||
@@ -778,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
title={previewFile.original_name}
|
title={previewFile.original_name}
|
||||||
>
|
>
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
<button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||||
@@ -589,7 +589,7 @@ export default function PlaceInspector({
|
|||||||
{filesExpanded && placeFiles.length > 0 && (
|
{filesExpanded && placeFiles.length > 0 && (
|
||||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{placeFiles.map(f => (
|
{placeFiles.map(f => (
|
||||||
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
|
||||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
@@ -587,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||||
<button type="button" onClick={async () => {
|
<button type="button" onClick={async () => {
|
||||||
// Always unlink, never delete the file
|
// Always unlink, never delete the file
|
||||||
// Clear primary reservation_id if it points to this reservation
|
// Clear primary reservation_id if it points to this reservation
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface AssignmentLookupEntry {
|
interface AssignmentLookupEntry {
|
||||||
@@ -253,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{attachedFiles.map(f => (
|
{attachedFiles.map(f => (
|
||||||
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
import { authApi } from '../api/client'
|
import { authApi } from '../api/client'
|
||||||
import { connect, disconnect } from '../api/websocket'
|
import { connect, disconnect } from '../api/websocket'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
@@ -55,7 +56,9 @@ interface AuthState {
|
|||||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||||
let authSequence = 0
|
let authSequence = 0
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -255,4 +258,25 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}),
|
||||||
|
{
|
||||||
|
name: 'trek_auth_snapshot',
|
||||||
|
// Only persist the minimal user snapshot needed to avoid redirecting to
|
||||||
|
// login when the PWA reopens offline. The JWT remains in the httpOnly
|
||||||
|
// cookie and is still validated by the server on every request.
|
||||||
|
// maps_api_key is intentionally excluded — it's an API key that should
|
||||||
|
// not sit in localStorage any longer than the active session requires.
|
||||||
|
partialize: (state) => ({
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
user: state.user ? {
|
||||||
|
id: state.user.id,
|
||||||
|
username: state.user.username,
|
||||||
|
email: state.user.email,
|
||||||
|
role: state.user.role,
|
||||||
|
avatar_url: state.user.avatar_url,
|
||||||
|
mfa_enabled: state.user.mfa_enabled,
|
||||||
|
must_change_password: state.user.must_change_password,
|
||||||
|
} : null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -439,4 +439,53 @@ describe('authStore', () => {
|
|||||||
uploadSpy.mockRestore();
|
uploadSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
||||||
|
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
|
||||||
|
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||||
|
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||||
|
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||||
|
expect(snapshot?.state?.user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-STORE-AUTH-PERSIST-002: 401 resets persisted snapshot', () => {
|
||||||
|
it('snapshot has isAuthenticated:false after 401 (expired session clears offline access)', async () => {
|
||||||
|
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/me', () =>
|
||||||
|
HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await useAuthStore.getState().loadUser();
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||||
|
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FE-STORE-AUTH-PERSIST-003: network error preserves snapshot', () => {
|
||||||
|
it('snapshot retains isAuthenticated:true on network error (offline PWA skips login screen)', async () => {
|
||||||
|
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/me', () =>
|
||||||
|
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await useAuthStore.getState().loadUser();
|
||||||
|
|
||||||
|
// Persist middleware writes the state; isAuthenticated must stay true
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||||
|
expect(snapshot?.state?.isAuthenticated).toBe(true);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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', () => {
|
||||||
|
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
|
const mockWin = { closed: false }
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
||||||
|
|
||||||
|
await openFile('/uploads/files/doc.pdf')
|
||||||
|
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
||||||
|
|
||||||
|
// Revoke happens after 30s timeout
|
||||||
|
vi.runAllTimers()
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to anchor download when popup is blocked', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||||
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await openFile('/uploads/files/doc.pdf')
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
|
vi.runAllTimers()
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
||||||
|
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(() => {})
|
||||||
|
|
||||||
|
await openFile('/uploads/files/malicious.html')
|
||||||
|
|
||||||
|
// Must NOT open inline — download anchor clicked instead
|
||||||
|
expect(openSpy).not.toHaveBeenCalled()
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||||
|
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await openFile('/uploads/files/malicious.svg')
|
||||||
|
|
||||||
|
expect(window.open).not.toHaveBeenCalled()
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user