Files
TREK/client/src/utils/fileDownload.ts
T
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00

138 lines
5.4 KiB
TypeScript

import { getCachedBlob } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
import { isIosStandalone } from './iosDevice'
// 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)
}
/**
* 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" hands off to Safari, which cannot reach a blob URL
// sandboxed to the installed app's WebView.
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)
}