Compare commits
4
Commits
900287b7aa
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30d0b3064e | ||
|
|
6259ca84d8 | ||
|
|
a6819809a7 | ||
|
|
16d2a56a78 |
+2
-2
@@ -16,10 +16,10 @@ S3_FORCE_PATH_STYLE=true
|
|||||||
OIDC_ISSUER=https://auth.example.com
|
OIDC_ISSUER=https://auth.example.com
|
||||||
OIDC_CLIENT_ID=photos
|
OIDC_CLIENT_ID=photos
|
||||||
OIDC_CLIENT_SECRET=change-me
|
OIDC_CLIENT_SECRET=change-me
|
||||||
ALLOWED_EMAILS=u797+claude@posteo.de
|
ALLOWED_EMAILS=you@example.com
|
||||||
|
|
||||||
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
|
# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production.
|
||||||
# DEV_AUTOLOGIN_EMAIL=u797+claude@posteo.de
|
# DEV_AUTOLOGIN_EMAIL=you@example.com
|
||||||
|
|
||||||
WORKER_CONCURRENCY=2
|
WORKER_CONCURRENCY=2
|
||||||
RUST_LOG=info,sqlx=warn
|
RUST_LOG=info,sqlx=warn
|
||||||
|
|||||||
@@ -5,23 +5,6 @@ share them with clients via private (optionally password-protected) links,
|
|||||||
collect accept/reject votes, ratings and tags, and let clients download
|
collect accept/reject votes, ratings and tags, and let clients download
|
||||||
originals.
|
originals.
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
The album view — drag-and-drop upload, justified gallery, and each client's
|
|
||||||
feedback (votes, stars, tags) overlaid on the thumbnails:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
What clients see on a share link — vote on favorites, filter by verdict,
|
|
||||||
download selects or the whole album as a ZIP:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
|
|
||||||
(`P`/`X`/`U`, `1`–`5`):
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 400 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 528 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
@@ -36,13 +36,14 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
|||||||
const [width, setWidth] = useState(0)
|
const [width, setWidth] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||||
observer.observe(containerRef.current)
|
observer.observe(el)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// The container renders even with zero photos — unmounting it would detach
|
if (photos.length === 0) return null
|
||||||
// the observer and leave a later non-empty render stuck at width 0.
|
|
||||||
const gap = 6
|
const gap = 6
|
||||||
const targetHeight = width < 700 ? 170 : 240
|
const targetHeight = width < 700 ? 170 : 240
|
||||||
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
||||||
@@ -68,7 +69,7 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
|||||||
title={isSelected ? 'Deselect' : 'Select'}
|
title={isSelected ? 'Deselect' : 'Select'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelect(p.id, e.shiftKey)
|
onToggleSelect(p.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export default function SelectionBar({
|
|||||||
onClear,
|
onClear,
|
||||||
onDownload,
|
onDownload,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDelete,
|
|
||||||
}) {
|
}) {
|
||||||
if (total === 0) return null
|
if (total === 0) return null
|
||||||
|
|
||||||
@@ -48,11 +47,6 @@ export default function SelectionBar({
|
|||||||
<button className="btn btn-primary" onClick={onDownload}>
|
<button className="btn btn-primary" onClick={onDownload}>
|
||||||
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
Download {count} as ZIP ({fmtBytes(selectedBytes)})
|
||||||
</button>
|
</button>
|
||||||
{onDelete && (
|
|
||||||
<button className="btn btn-danger" onClick={onDelete}>
|
|
||||||
Delete {count}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,67 +18,6 @@ function fmtEta(seconds) {
|
|||||||
|
|
||||||
const UPLOAD_CONCURRENCY = 3
|
const UPLOAD_CONCURRENCY = 3
|
||||||
|
|
||||||
// Expiry convention, in one place for the create form and the row editor:
|
|
||||||
// end of the chosen day in the photographer's local timezone — date-only
|
|
||||||
// strings would parse as UTC midnight and expire a day early.
|
|
||||||
const endOfDayIso = (day) => (day ? new Date(`${day}T23:59:59`).toISOString() : null)
|
|
||||||
|
|
||||||
// ISO timestamp -> local yyyy-mm-dd for date inputs.
|
|
||||||
const localDate = (iso) => {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Staged expiry editor: commits on blur/Enter, never per keystroke — typing
|
|
||||||
// a year fires change events with bogus intermediate dates (year 0002) that
|
|
||||||
// must not hit the live link. The ✕ clears explicitly; Safari's date input
|
|
||||||
// has no native clear control.
|
|
||||||
function ExpiryDate({ value, onCommit }) {
|
|
||||||
const current = value ? localDate(value) : ''
|
|
||||||
const [draft, setDraft] = useState(current)
|
|
||||||
useEffect(() => setDraft(current), [current])
|
|
||||||
|
|
||||||
const commit = () => {
|
|
||||||
if (draft === current) return
|
|
||||||
// A half-typed year (e.g. 0002) can survive until blur; don't persist it.
|
|
||||||
if (draft && draft.slice(0, 4) < '2000') {
|
|
||||||
setDraft(current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onCommit(endOfDayIso(draft))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="row field-label" title="Expiry date — empty means the link never expires">
|
|
||||||
<span className="muted">expires</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onBlur={commit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') e.target.blur()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{draft ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
title="Remove expiry — link never expires"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft('')
|
|
||||||
onCommit(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="muted">never</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UploadZone({ albumId, onUploaded }) {
|
function UploadZone({ albumId, onUploaded }) {
|
||||||
const [queue, setQueue] = useState([])
|
const [queue, setQueue] = useState([])
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
@@ -259,7 +198,11 @@ function SharesPanel({ albumId }) {
|
|||||||
label: form.label,
|
label: form.label,
|
||||||
password: form.password || null,
|
password: form.password || null,
|
||||||
allow_download: form.allow_download,
|
allow_download: form.allow_download,
|
||||||
expires_at: endOfDayIso(form.expires_at),
|
// End of the chosen day in the photographer's local timezone —
|
||||||
|
// date-only strings would parse as UTC midnight and expire a day early.
|
||||||
|
expires_at: form.expires_at
|
||||||
|
? new Date(`${form.expires_at}T23:59:59`).toISOString()
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
|
||||||
@@ -276,18 +219,6 @@ function SharesPanel({ albumId }) {
|
|||||||
setTimeout(() => setCopied(null), 1500)
|
setTimeout(() => setCopied(null), 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = async (shareId, patch) => {
|
|
||||||
try {
|
|
||||||
const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
|
|
||||||
setShares((list) => list.map((s) => (s.id === shareId ? updated : s)))
|
|
||||||
setError(null)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message)
|
|
||||||
// Re-sync the controlled inputs with what the server actually has.
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<h2>Client links</h2>
|
<h2>Client links</h2>
|
||||||
@@ -302,20 +233,16 @@ function SharesPanel({ albumId }) {
|
|||||||
<span className="muted">
|
<span className="muted">
|
||||||
{s.has_password ? '🔒 password' : 'no password'}
|
{s.has_password ? '🔒 password' : 'no password'}
|
||||||
{' · '}
|
{' · '}
|
||||||
|
{s.allow_download ? 'downloads on' : 'downloads off'}
|
||||||
|
{s.expires_at
|
||||||
|
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
|
||||||
|
: ' · never expires'}
|
||||||
|
{' · '}
|
||||||
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
{s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
|
||||||
{s.reject_count}
|
{s.reject_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={s.allow_download}
|
|
||||||
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
|
|
||||||
/>
|
|
||||||
downloads
|
|
||||||
</label>
|
|
||||||
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
|
|
||||||
{s.locked && (
|
{s.locked && (
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -450,18 +377,6 @@ export default function AlbumPage() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSelected = async () => {
|
|
||||||
if (!confirm(`Delete ${selected.size} selected photo${selected.size === 1 ? '' : 's'}? This cannot be undone.`))
|
|
||||||
return
|
|
||||||
try {
|
|
||||||
await api('/api/photos/delete', { method: 'POST', body: { ids: [...selected] } })
|
|
||||||
clear()
|
|
||||||
} catch (e) {
|
|
||||||
alert(`Delete failed: ${e.message}`)
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -547,7 +462,6 @@ export default function AlbumPage() {
|
|||||||
onClear={clear}
|
onClear={clear}
|
||||||
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
|
||||||
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
|
||||||
onDelete={removeSelected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lightbox.index >= 0 && (
|
{lightbox.index >= 0 && (
|
||||||
|
|||||||
@@ -193,11 +193,7 @@ export default function SharePage() {
|
|||||||
photos={visible}
|
photos={visible}
|
||||||
onOpen={lightbox.openAt}
|
onOpen={lightbox.openAt}
|
||||||
selected={view.allow_download ? selected : undefined}
|
selected={view.allow_download ? selected : undefined}
|
||||||
// Shift-ranges must walk the filtered view, not the full album —
|
onToggleSelect={view.allow_download ? toggle : undefined}
|
||||||
// otherwise hidden photos get swept into the selection.
|
|
||||||
onToggleSelect={
|
|
||||||
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
|
|
||||||
}
|
|
||||||
overlay={(p) =>
|
overlay={(p) =>
|
||||||
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
|
||||||
<div className="g-overlay">
|
<div className="g-overlay">
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
// Multi-select over a photo list. Selection lives here (not in the gallery)
|
||||||
// so it survives lightbox open/close, and is pruned automatically when
|
// so it survives lightbox open/close, and is pruned automatically when
|
||||||
// photos disappear from the list (deletes, polling refreshes).
|
// photos disappear from the list (deletes, polling refreshes).
|
||||||
export default function useSelection(photos) {
|
export default function useSelection(photos) {
|
||||||
const [selected, setSelected] = useState(() => new Set())
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
// Anchor for shift-click range selection: the photo last toggled.
|
|
||||||
const lastToggled = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -17,43 +15,19 @@ export default function useSelection(photos) {
|
|||||||
})
|
})
|
||||||
}, [photos])
|
}, [photos])
|
||||||
|
|
||||||
// Shift-toggle selects the whole range from the previously toggled photo
|
const toggle = (photoId) =>
|
||||||
// (both directions), so contiguous runs don't need per-photo clicks. The
|
|
||||||
// range walks `list` — the photo order the user is actually looking at —
|
|
||||||
// which callers with a filtered view must pass explicitly, so hidden
|
|
||||||
// photos are never swept into the selection. Anchor updates and range
|
|
||||||
// computation stay out of the setSelected updater: React may re-invoke
|
|
||||||
// updaters (StrictMode does), so they must be pure.
|
|
||||||
const toggle = (photoId, shift = false, list = photos) => {
|
|
||||||
const anchor = lastToggled.current
|
|
||||||
lastToggled.current = photoId
|
|
||||||
if (shift && anchor) {
|
|
||||||
const a = list.findIndex((p) => p.id === anchor)
|
|
||||||
const b = list.findIndex((p) => p.id === photoId)
|
|
||||||
if (a >= 0 && b >= 0) {
|
|
||||||
const range = list.slice(Math.min(a, b), Math.max(a, b) + 1).map((p) => p.id)
|
|
||||||
setSelected((prev) => new Set([...prev, ...range]))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(photoId)) next.delete(photoId)
|
if (next.has(photoId)) next.delete(photoId)
|
||||||
else next.add(photoId)
|
else next.add(photoId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Adds `list` (the caller's currently visible photos) to the selection —
|
// Adds `list` (the caller's currently visible photos) to the selection —
|
||||||
// additive, so selecting all of one filtered view keeps picks from another.
|
// additive, so selecting all of one filtered view keeps picks from another.
|
||||||
const selectAll = (list) =>
|
const selectAll = (list) =>
|
||||||
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
|
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
|
||||||
const clear = () => {
|
const clear = () => setSelected(new Set())
|
||||||
// Reset the anchor too — a shift-click after Clear must start fresh, not
|
|
||||||
// extend a range from a photo selected before the wipe.
|
|
||||||
lastToggled.current = null
|
|
||||||
setSelected(new Set())
|
|
||||||
}
|
|
||||||
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
|
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
|
||||||
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
|
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::models::{Album, Photo, PhotoStatus};
|
use crate::models::{Album, JobKind, Photo, PhotoStatus};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize, sqlx::FromRow)]
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
@@ -232,13 +232,17 @@ pub async fn delete(
|
|||||||
if locked.is_none() {
|
if locked.is_none() {
|
||||||
return Err(ApiError::not_found());
|
return Err(ApiError::not_found());
|
||||||
}
|
}
|
||||||
// Delete photos through the shared path so the S3 cleanup convention
|
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
|
||||||
// lives in one place; the album lock above keeps this set complete.
|
sqlx::query(
|
||||||
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1")
|
"with deleted as (delete from photos where album_id = $1 returning id)
|
||||||
.bind(album_id)
|
insert into jobs (kind, payload)
|
||||||
.fetch_all(&mut *tx)
|
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
|
||||||
.await?;
|
from deleted",
|
||||||
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
|
)
|
||||||
|
.bind(album_id)
|
||||||
|
.bind(JobKind::DeleteS3Prefix.as_str())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
sqlx::query("delete from albums where id = $1")
|
sqlx::query("delete from albums where id = $1")
|
||||||
.bind(album_id)
|
.bind(album_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|||||||
+1
-5
@@ -78,13 +78,9 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
.route("/api/albums/{id}/zip", post(zip::album_zip))
|
||||||
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
|
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
|
||||||
.route("/api/photos/delete", post(photos::delete_many))
|
|
||||||
.route("/api/photos/{id}", delete(photos::delete))
|
.route("/api/photos/{id}", delete(photos::delete))
|
||||||
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
|
||||||
.route(
|
.route("/api/shares/{id}", delete(shares::delete))
|
||||||
"/api/shares/{id}",
|
|
||||||
delete(shares::delete).patch(shares::update),
|
|
||||||
)
|
|
||||||
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|||||||
+11
-52
@@ -189,69 +189,28 @@ pub async fn by_hash(
|
|||||||
photo.map(Json).ok_or_else(ApiError::not_found)
|
photo.map(Json).ok_or_else(ApiError::not_found)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one deletion path: delete the given photos and enqueue their S3
|
|
||||||
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
|
|
||||||
/// so the key layout has a single source of truth. Returns how many photos
|
|
||||||
/// were actually deleted.
|
|
||||||
pub(super) async fn delete_with_cleanup(
|
|
||||||
tx: &mut sqlx::PgConnection,
|
|
||||||
ids: &[Uuid],
|
|
||||||
) -> Result<u64, sqlx::Error> {
|
|
||||||
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
|
|
||||||
.bind(ids)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if deleted.is_empty() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
|
|
||||||
sqlx::query(
|
|
||||||
"insert into jobs (kind, payload)
|
|
||||||
select $1, jsonb_build_object('prefix', p)
|
|
||||||
from unnest($2::text[]) as p",
|
|
||||||
)
|
|
||||||
.bind(JobKind::DeleteS3Prefix.as_str())
|
|
||||||
.bind(&prefixes)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
Ok(deleted.len() as u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
) -> ApiResult<Json<serde_json::Value>> {
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
|
let deleted = sqlx::query("delete from photos where id = $1")
|
||||||
|
.bind(photo_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if deleted.rows_affected() == 0 {
|
||||||
return Err(ApiError::not_found());
|
return Err(ApiError::not_found());
|
||||||
}
|
}
|
||||||
|
jobs::enqueue(
|
||||||
|
&mut *tx,
|
||||||
|
JobKind::DeleteS3Prefix,
|
||||||
|
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct DeleteManyBody {
|
|
||||||
ids: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_many(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<DeleteManyBody>,
|
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
|
||||||
if body.ids.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("ids must not be empty"));
|
|
||||||
}
|
|
||||||
let mut tx = state.db.begin().await?;
|
|
||||||
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
|
|
||||||
// Consistent with the single-photo route: deleting nothing is an error,
|
|
||||||
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
|
|
||||||
if deleted == 0 {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reprocess(
|
pub async fn reprocess(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(photo_id): Path<Uuid>,
|
Path(photo_id): Path<Uuid>,
|
||||||
|
|||||||
@@ -138,49 +138,6 @@ pub async fn create(
|
|||||||
Ok(Json(share_json(&state, &row)))
|
Ok(Json(share_json(&state, &row)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinguishes an absent JSON field (keep current value) from an explicit
|
|
||||||
/// null (clear the expiry): absent → None, present → Some(inner).
|
|
||||||
fn double_option<'de, D>(de: D) -> Result<Option<Option<DateTime<Utc>>>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
serde::Deserialize::deserialize(de).map(Some)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct UpdateShare {
|
|
||||||
allow_download: Option<bool>,
|
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
|
||||||
expires_at: Option<Option<DateTime<Utc>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(share_id): Path<Uuid>,
|
|
||||||
Json(body): Json<UpdateShare>,
|
|
||||||
) -> ApiResult<Json<serde_json::Value>> {
|
|
||||||
let updated = sqlx::query(
|
|
||||||
"update shares set
|
|
||||||
allow_download = coalesce($2, allow_download),
|
|
||||||
expires_at = case when $3 then $4 else expires_at end
|
|
||||||
where id = $1",
|
|
||||||
)
|
|
||||||
.bind(share_id)
|
|
||||||
.bind(body.allow_download)
|
|
||||||
.bind(body.expires_at.is_some())
|
|
||||||
.bind(body.expires_at.flatten())
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
if updated.rows_affected() == 0 {
|
|
||||||
return Err(ApiError::not_found());
|
|
||||||
}
|
|
||||||
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
|
|
||||||
.bind(share_id)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(share_json(&state, &row)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
|
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
|
||||||
/// their way into the 15-minute lock).
|
/// their way into the 15-minute lock).
|
||||||
pub async fn reset_lock(
|
pub async fn reset_lock(
|
||||||
|
|||||||
Reference in New Issue
Block a user