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)
}