Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13s

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:
2026-07-17 13:12:42 +02:00
co-authored by Claude
commit 16d2a56a78
55 changed files with 11962 additions and 0 deletions
+100
View File
@@ -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)
})
}