| 1 |
// Shared S3 upload primitives, ported from static/upload.js and typed. Adds |
| 2 |
// `presignPutConfirm`, which unifies the presign→PUT→confirm orchestration that |
| 3 |
// the legacy upload sites each reimplemented. |
| 4 |
// |
| 5 |
// Security note: the client-supplied `content_type` and file are advisory only. |
| 6 |
// The server's presign/confirm endpoints and the async malware-scan gate remain |
| 7 |
// authoritative — nothing here should be treated as trusted by the backend. |
| 8 |
|
| 9 |
import { formatSpeed, formatEta } from './s3.logic.ts'; |
| 10 |
import { csrfHeaders, apiError } from '../../core/net.ts'; |
| 11 |
|
| 12 |
export interface UploaderEls { |
| 13 |
filenameEl?: HTMLElement | null; |
| 14 |
percentEl?: HTMLElement | null; |
| 15 |
progressBar?: HTMLElement | null; |
| 16 |
speedEl?: HTMLElement | null; |
| 17 |
} |
| 18 |
|
| 19 |
/** XHR PUT to a presigned URL with progress/speed/ETA reporting. */ |
| 20 |
export class S3Uploader { |
| 21 |
#els: UploaderEls; |
| 22 |
#xhr: XMLHttpRequest | null = null; |
| 23 |
|
| 24 |
constructor(els: UploaderEls) { |
| 25 |
this.#els = els; |
| 26 |
} |
| 27 |
|
| 28 |
/** Start an upload; resolves to `s3Key` on 2xx. */ |
| 29 |
upload(url: string, file: File, s3Key: string, fallbackContentType?: string, cacheControl?: string): Promise<string> { |
| 30 |
const els = this.#els; |
| 31 |
const startTime = Date.now(); |
| 32 |
if (els.filenameEl) els.filenameEl.textContent = file.name; |
| 33 |
if (els.percentEl) els.percentEl.textContent = '0%'; |
| 34 |
if (els.progressBar) els.progressBar.style.width = '0%'; |
| 35 |
if (els.speedEl) els.speedEl.textContent = ''; |
| 36 |
|
| 37 |
return new Promise((resolve, reject) => { |
| 38 |
const xhr = new XMLHttpRequest(); |
| 39 |
this.#xhr = xhr; |
| 40 |
|
| 41 |
xhr.upload.addEventListener('progress', (e) => { |
| 42 |
if (!e.lengthComputable) return; |
| 43 |
const percent = Math.round((e.loaded / e.total) * 100); |
| 44 |
if (els.percentEl) els.percentEl.textContent = percent + '%'; |
| 45 |
if (els.progressBar) els.progressBar.style.width = percent + '%'; |
| 46 |
if (els.speedEl && e.loaded > 0) { |
| 47 |
const elapsed = (Date.now() - startTime) / 1000; |
| 48 |
if (elapsed > 0.5) { |
| 49 |
const speed = e.loaded / elapsed; |
| 50 |
const remaining = (e.total - e.loaded) / speed; |
| 51 |
els.speedEl.textContent = `${formatSpeed(speed)} — ${formatEta(remaining)} remaining`; |
| 52 |
} |
| 53 |
} |
| 54 |
}); |
| 55 |
|
| 56 |
xhr.addEventListener('load', () => { |
| 57 |
this.#xhr = null; |
| 58 |
if (xhr.status >= 200 && xhr.status < 300) resolve(s3Key); |
| 59 |
else reject(new Error('Upload failed: ' + xhr.status)); |
| 60 |
}); |
| 61 |
xhr.addEventListener('error', () => { |
| 62 |
this.#xhr = null; |
| 63 |
reject(new Error('Network error during upload')); |
| 64 |
}); |
| 65 |
xhr.addEventListener('abort', () => { |
| 66 |
this.#xhr = null; |
| 67 |
reject(new Error('Upload cancelled')); |
| 68 |
}); |
| 69 |
|
| 70 |
xhr.open('PUT', url); |
| 71 |
xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream'); |
| 72 |
if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl); |
| 73 |
xhr.send(file); |
| 74 |
}); |
| 75 |
} |
| 76 |
|
| 77 |
/** Abort any in-progress upload. */ |
| 78 |
cancel(): void { |
| 79 |
if (this.#xhr) { |
| 80 |
this.#xhr.abort(); |
| 81 |
this.#xhr = null; |
| 82 |
} |
| 83 |
} |
| 84 |
} |
| 85 |
|
| 86 |
/** Wire drag/drop + click-to-pick on a dropzone; `onFile` gets the chosen file. */ |
| 87 |
export function initDropzone(dropzoneEl: HTMLElement, fileInputEl: HTMLInputElement, onFile: (file: File) => void): void { |
| 88 |
dropzoneEl.addEventListener('dragover', (e) => { |
| 89 |
e.preventDefault(); |
| 90 |
dropzoneEl.classList.add('dragover'); |
| 91 |
}); |
| 92 |
dropzoneEl.addEventListener('dragleave', () => dropzoneEl.classList.remove('dragover')); |
| 93 |
dropzoneEl.addEventListener('drop', (e) => { |
| 94 |
e.preventDefault(); |
| 95 |
dropzoneEl.classList.remove('dragover'); |
| 96 |
const f = e.dataTransfer?.files[0]; |
| 97 |
if (f) onFile(f); |
| 98 |
}); |
| 99 |
dropzoneEl.addEventListener('click', () => fileInputEl.click()); |
| 100 |
fileInputEl.addEventListener('change', () => { |
| 101 |
const f = fileInputEl.files?.[0]; |
| 102 |
if (f) onFile(f); |
| 103 |
}); |
| 104 |
} |
| 105 |
|
| 106 |
interface PresignResponse { |
| 107 |
upload_url: string; |
| 108 |
s3_key: string; |
| 109 |
cache_control?: string; |
| 110 |
} |
| 111 |
|
| 112 |
export interface PresignPutConfirmOpts { |
| 113 |
presignUrl: string; |
| 114 |
confirmUrl: string; |
| 115 |
presignBody: Record<string, unknown>; |
| 116 |
/** Build the confirm body from the returned s3 key. */ |
| 117 |
confirmBody: (s3Key: string) => Record<string, unknown>; |
| 118 |
file: File; |
| 119 |
uploader: S3Uploader; |
| 120 |
fallbackContentType?: string; |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* The full presign → PUT-to-S3 → confirm round trip, with the server error |
| 125 |
* message surfaced on failure at each step. The single implementation the |
| 126 |
* upload sites should share. |
| 127 |
*/ |
| 128 |
export async function presignPutConfirm<T>(opts: PresignPutConfirmOpts): Promise<T> { |
| 129 |
const presignRes = await fetch(opts.presignUrl, { |
| 130 |
method: 'POST', |
| 131 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 132 |
body: JSON.stringify(opts.presignBody), |
| 133 |
credentials: 'same-origin', |
| 134 |
}); |
| 135 |
if (!presignRes.ok) throw new Error(await apiError(presignRes, 'Failed to get upload URL')); |
| 136 |
const data = (await presignRes.json()) as PresignResponse; |
| 137 |
|
| 138 |
const s3Key = await opts.uploader.upload(data.upload_url, opts.file, data.s3_key, opts.fallbackContentType, data.cache_control); |
| 139 |
|
| 140 |
const confirmRes = await fetch(opts.confirmUrl, { |
| 141 |
method: 'POST', |
| 142 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 143 |
body: JSON.stringify(opts.confirmBody(s3Key)), |
| 144 |
credentials: 'same-origin', |
| 145 |
}); |
| 146 |
if (!confirmRes.ok) throw new Error(await apiError(confirmRes, 'Failed to confirm upload')); |
| 147 |
return (await confirmRes.json()) as T; |
| 148 |
} |
| 149 |
|