ci / docker (push) Successful in 12s
- sha256+crc32 hashed during upload streaming; unique index per album - duplicate content returns the existing photo (race-safe via 23505) - client hashes locally (WebCrypto) and skips the transfer entirely for content the album already has - zip downloads stream S3->response directly using the stored crc32; pre-hash photos spool once and self-heal (crc via zip, sha via reprocess) - upload UI: overall progress bar, bytes, live speed, ETA
108 lines
3.5 KiB
JavaScript
108 lines
3.5 KiB
JavaScript
export async function api(path, opts = {}) {
|
|
const { body, ...rest } = opts
|
|
const res = await fetch(path, {
|
|
...rest,
|
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
})
|
|
if (!res.ok) {
|
|
let message = res.statusText
|
|
try {
|
|
message = (await res.json()).error || message
|
|
} catch {
|
|
/* not json */
|
|
}
|
|
const err = new Error(message)
|
|
err.status = res.status
|
|
throw err
|
|
}
|
|
if (res.status === 204) return null
|
|
return res.json()
|
|
}
|
|
|
|
// Image URL with a cache-buster tied to the last processing run, so
|
|
// reprocessed photos bypass the long-lived immutable browser cache.
|
|
// Auth rides on cookies (session or share-access), never in the URL.
|
|
export function imgUrl(photo, size) {
|
|
const version = photo.processed_at ? `?v=${encodeURIComponent(photo.processed_at)}` : ''
|
|
return `/api/img/${photo.id}/${size}${version}`
|
|
}
|
|
|
|
// Trigger a browser-native download from a POST endpoint (e.g. zip streams)
|
|
// via a hidden form — fetch+blob would buffer the whole file in memory.
|
|
// Targets a hidden iframe so an error response can't navigate away from the
|
|
// app (which would lose selection/rating state); errors surface as an alert.
|
|
export function postDownload(url, ids = '') {
|
|
let frame = document.getElementById('download-frame')
|
|
if (!frame) {
|
|
frame = document.createElement('iframe')
|
|
frame.id = 'download-frame'
|
|
frame.name = 'download-frame'
|
|
frame.style.display = 'none'
|
|
document.body.appendChild(frame)
|
|
}
|
|
frame.onload = () => {
|
|
// load only fires when the response rendered (i.e. an error body);
|
|
// successful attachment downloads never trigger it.
|
|
let message = 'download failed'
|
|
try {
|
|
const text = frame.contentDocument?.body?.textContent
|
|
if (!text) return
|
|
try {
|
|
message = JSON.parse(text).error || message
|
|
} catch {
|
|
/* not json */
|
|
}
|
|
} catch {
|
|
return
|
|
}
|
|
alert(`Download failed: ${message}`)
|
|
}
|
|
const form = document.createElement('form')
|
|
form.method = 'POST'
|
|
form.action = url
|
|
form.target = 'download-frame'
|
|
form.style.display = 'none'
|
|
const input = document.createElement('input')
|
|
input.type = 'hidden'
|
|
input.name = 'ids'
|
|
input.value = ids
|
|
form.appendChild(input)
|
|
document.body.appendChild(form)
|
|
form.submit()
|
|
form.remove()
|
|
}
|
|
|
|
// SHA-256 of a File, matching the server's content hash — used to skip
|
|
// uploading bytes the album already has.
|
|
export async function sha256Hex(file) {
|
|
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
export function uploadFile(url, file, onProgress) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('POST', url)
|
|
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
|
|
}
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(JSON.parse(xhr.responseText))
|
|
} else {
|
|
let message = `upload failed (${xhr.status})`
|
|
try {
|
|
message = JSON.parse(xhr.responseText).error || message
|
|
} catch {
|
|
/* not json */
|
|
}
|
|
reject(new Error(message))
|
|
}
|
|
}
|
|
xhr.onerror = () => reject(new Error('network error during upload'))
|
|
xhr.send(file)
|
|
})
|
|
}
|