Spaces:
Sleeping
Sleeping
File size: 5,225 Bytes
cd99321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | import { getCachedBlob } from '../db/offlineDb'
// 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)
}
// navigator.standalone is true only on iOS when running as an
// add-to-home-screen PWA. In that context, target="_blank" hands off to
// Safari, which cannot access blob URLs sandboxed to the WebView.
function isIosStandalone(): boolean {
return (navigator as any).standalone === true
}
/**
* Resolves a protected file to a Blob, preferring the live server but falling
* back to the offline cache (pre-downloaded by the trip sync manager). This is
* what lets attachments open in a PWA / airplane mode. When offline we go
* straight to the cache; when online we fetch live and only fall back if the
* network actually fails — which also covers flaky links where navigator.onLine
* still reports true ("sometimes it works, sometimes it doesn't").
*/
async function getFileBlob(url: string): Promise<Blob> {
assertRelativeUrl(url)
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
const cached = await getCachedBlob(url)
if (cached) return cached
throw new Error('File not available offline')
}
let resp: Response
try {
resp = await fetch(url, { credentials: 'include' })
} catch (err) {
// Genuine network failure — the fetch itself rejected (offline, or a flaky
// link even though navigator.onLine is true). Serve the pre-downloaded copy.
const cached = await getCachedBlob(url)
if (cached) return cached
throw err
}
// The server answered: a non-ok status (401/403/404/…) is a real error and must
// surface, not be masked by a stale cached copy.
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
return await resp.blob()
}
/**
* 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). Falls back to the offline
* cache when the network is unavailable.
*/
export async function downloadFile(url: string, filename?: string): Promise<void> {
const blob = await getFileBlob(url)
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.
*
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
* than window.open(). window.open() called with the "noreferrer"/"noopener"
* window feature returns null per spec, which previously made the popup-block
* fallback trigger a download in the *current* tab on top of the new-tab open
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
* the new tab is opened by the browser's normal link-handling path, and no
* spurious in-page download is triggered.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
const blob = await getFileBlob(url)
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
}
// iOS PWA: target="_blank" would open Safari, which can't access the blob
if (isIosStandalone()) {
triggerAnchorDownload(blobUrl, filename)
return
}
const a = document.createElement('a')
a.href = blobUrl
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
// Keep the blob URL alive long enough for the new tab to load it, then
// clean up the DOM node and revoke the URL.
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
}
|