mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
72a82b3c44
214 items came out of writing the tests. Real defects are corrected, dead code is removed rather than covered, and the tests that pinned the old behaviour move with them. Also drops two agent coverage scratch files that slipped into the coverage PR and adds the ignore rule that keeps them out.
143 lines
5.6 KiB
TypeScript
143 lines
5.6 KiB
TypeScript
import { getCachedBlob } from '../db/offlineDb'
|
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
|
|
|
// 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' && isEffectivelyOffline()) {
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Triggers a browser download for an in-memory Blob (CSV export, generated
|
|
* report, …). Use this instead of hand-rolling the anchor: Firefox ignores a
|
|
* click on an anchor that is not in the document, and revoking the object URL
|
|
* in the same tick can abort the download in other browsers.
|
|
*/
|
|
export function downloadBlob(blob: Blob, filename: string): void {
|
|
const blobUrl = URL.createObjectURL(blob)
|
|
triggerAnchorDownload(blobUrl, filename)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|