Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue (SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived keys and a fully private bucket, OIDC photographer login with per-request allowlist checks, client share links with argon2 passwords and lockout, cookie-based image authorization with sliding expiry, hand-rolled spec-compliant streaming ZIP downloads with exact Content-Length, React + Vite gallery frontend, single Docker image, Helm chart for external S3 + Postgres, and Gitea CI. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user