mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
650451bdf7
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.
138 lines
5.4 KiB
TypeScript
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)
|
|
}
|