Files
photos/frontend/src/api.js
T
nils 6259ca84d8
ci / docker (push) Successful in 12s
Content dedup on both sides, stored checksums, spool-free zips, upload stats
- 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
2026-07-17 14:41:07 +02:00

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