@hearthlog/module-gallery 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +41 -0
- package/package.json +42 -3
- package/public/app/App.svelte +448 -0
- package/public/app/ambient.d.ts +5 -0
- package/public/app/client.ts +7 -0
- package/public/app/types.ts +53 -0
- package/public/client-assets.ts +17 -0
- package/public/contract.ts +211 -0
- package/public/index.ts +237 -0
- package/public/tsconfig.json +21 -0
- package/src/components/gallery/AlbumCard.svelte +41 -0
- package/src/components/gallery/AlbumSection.svelte +323 -0
- package/src/components/gallery/GalleryMonthBlock.svelte +159 -0
- package/src/components/gallery/GalleryTimeline.svelte +294 -0
- package/src/components/gallery/PhotoThumb.svelte +26 -0
- package/src/components/gallery/PhotoTile.svelte +163 -0
- package/src/components/gallery/PhotoViewer.svelte +219 -0
- package/src/components/gallery/PublicationManager.svelte +386 -0
- package/src/components/gallery/PublicationPreviewPhoto.svelte +33 -0
- package/src/components/gallery/gallery-blocks.svelte.ts +30 -0
- package/src/components/gallery/view/GallerySpine.svelte +239 -0
- package/src/components/gallery/view/GalleryTile.svelte +157 -0
- package/src/components/gallery/view/JustifiedRows.svelte +54 -0
- package/src/components/gallery/view/MonthHeading.svelte +53 -0
- package/src/components/gallery/view/ViewerFrame.svelte +362 -0
- package/src/data/definitions.ts +93 -0
- package/src/data/gallery.ts +413 -0
- package/src/data/repositories.ts +8 -0
- package/src/gallery/backup-archive.ts +195 -0
- package/src/gallery/backup.ts +140 -0
- package/src/gallery/exif.ts +240 -0
- package/src/gallery/file-store.ts +206 -0
- package/src/gallery/format.ts +101 -0
- package/src/gallery/interaction.ts +6 -0
- package/src/gallery/layout.ts +67 -0
- package/src/gallery/public-details.ts +73 -0
- package/src/gallery/publication-api.ts +68 -0
- package/src/gallery/selection.ts +25 -0
- package/src/gallery/timeline.ts +77 -0
- package/src/pages/gallery.svelte +737 -0
- package/src/pages/gallery.ts +20 -0
- package/src/server/files.ts +229 -0
- package/src/server/index.ts +18 -0
- package/src/server/ledger.ts +42 -0
- package/src/server/publications.ts +576 -0
- package/src/startup.ts +2 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { authorizeStorRequest, LedgerAdminConfigurationError, type LedgerAdminIdentity } from '@hearthlog/sdk/server';
|
|
2
|
+
import { LedgerScopeError, verifyLogAppId } from '@hearthlog/sdk/server';
|
|
3
|
+
import { verifyGalleryPhotoReference } from './ledger.js';
|
|
4
|
+
import { readBodyCapped } from '@hearthlog/sdk/server';
|
|
5
|
+
import {
|
|
6
|
+
activeKey, assetKey, idsKey, indexKey, pageKey, parsePublicManifest, publicIdShards, publicMonths, UUID, validPhotoDetails,
|
|
7
|
+
type PublicGalleryManifest, type PublicGalleryIndex, type PublicGalleryPage, type PublicGalleryPhotoDetails,
|
|
8
|
+
} from '../../public/contract.js';
|
|
9
|
+
|
|
10
|
+
const PREFIX = '/api/gallery-publications/v1';
|
|
11
|
+
const ID = '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})';
|
|
12
|
+
const ROOT = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}$`);
|
|
13
|
+
const REVISION = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}/revisions/${ID}$`);
|
|
14
|
+
const CHUNK = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}/revisions/${ID}/chunks/(0|[1-9][0-9]*)$`);
|
|
15
|
+
const ACTIVATE = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}/revisions/${ID}/activate$`);
|
|
16
|
+
const CLEANUP = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}/revisions/${ID}/cleanup$`);
|
|
17
|
+
const UNPUBLISH = new RegExp(`^${PREFIX}/logs/${ID}/publications/${ID}/unpublish$`);
|
|
18
|
+
const MAX_BODY = 16 * 1024 * 1024;
|
|
19
|
+
const CHUNK_SIZE = 8;
|
|
20
|
+
const MAX_PHOTOS = 10_000;
|
|
21
|
+
/** A revision nobody activated or resumed for this long is abandoned. */
|
|
22
|
+
const ABANDONED_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const SWEEP_PUBLICATIONS_PER_RUN = 100;
|
|
24
|
+
const SWEEP_CURSOR_KEY = 'v1/sweep/publications.json';
|
|
25
|
+
const PUBLICATIONS_PREFIX = 'v1/publications/';
|
|
26
|
+
|
|
27
|
+
export type PublicationEnv = Pick<Env,
|
|
28
|
+
'CF_ACCESS_APP_AUD' | 'CF_ACCESS_TEAM_DOMAIN' | 'STOR_AUTHORIZED_EMAILS' |
|
|
29
|
+
'LEDGER' | 'LEDGER_AUTH_TOKEN' | 'GALLERY_FILES' | 'GALLERY_PUBLIC' |
|
|
30
|
+
'GALLERY_PUBLIC_ORIGIN' | 'IMAGES'
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
/** What the pointer, cleanup queue, and sweep touch; they need neither Ledger nor Access. */
|
|
34
|
+
type StorageEnv = Pick<PublicationEnv, 'GALLERY_FILES' | 'GALLERY_PUBLIC'>;
|
|
35
|
+
|
|
36
|
+
type Authorizer = (request: Request, env: PublicationEnv) => Promise<LedgerAdminIdentity>;
|
|
37
|
+
|
|
38
|
+
interface SourcePhoto {
|
|
39
|
+
photoId: string;
|
|
40
|
+
fileVersionId: string;
|
|
41
|
+
assetId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ReviewPhoto extends PublicGalleryPhotoDetails {
|
|
45
|
+
photoId: string;
|
|
46
|
+
fileVersionId: string;
|
|
47
|
+
caption: string;
|
|
48
|
+
alt: string;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DETAIL_FIELDS = ['takenAt', 'camera', 'lens', 'fNumber', 'exposureTime', 'iso', 'focalLength', 'latitude', 'longitude'] as const;
|
|
54
|
+
|
|
55
|
+
/** The details an owner kept in the review, and nothing else the request carried. */
|
|
56
|
+
function sharedDetails(photo: PublicGalleryPhotoDetails): PublicGalleryPhotoDetails {
|
|
57
|
+
const details: Record<string, unknown> = {};
|
|
58
|
+
for (const field of DETAIL_FIELDS) {
|
|
59
|
+
const value = photo[field];
|
|
60
|
+
if (value !== undefined) details[field] = typeof value === 'string' ? value.trim() : value;
|
|
61
|
+
}
|
|
62
|
+
return details as PublicGalleryPhotoDetails;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ReviewRequest {
|
|
66
|
+
title: string;
|
|
67
|
+
description: string;
|
|
68
|
+
photos: ReviewPhoto[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Draft {
|
|
72
|
+
version: 1;
|
|
73
|
+
requestHash: string;
|
|
74
|
+
expectedEtag: string | null;
|
|
75
|
+
sources: SourcePhoto[];
|
|
76
|
+
manifest: PublicGalleryManifest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Owner { appId: string; logId: string }
|
|
80
|
+
|
|
81
|
+
function ownerKey(publicationId: string): string { return `v1/publications/${publicationId}/owner.json`; }
|
|
82
|
+
function draftKey(publicationId: string, revision: string): string { return `v1/publications/${publicationId}/revisions/${revision}/draft.json`; }
|
|
83
|
+
function markerPrefix(publicationId: string, revision: string): string { return `v1/publications/${publicationId}/revisions/${revision}/chunks/`; }
|
|
84
|
+
function markerKey(publicationId: string, revision: string, chunk: number): string { return `${markerPrefix(publicationId, revision)}${chunk}.json`; }
|
|
85
|
+
function cleanupPrefix(publicationId: string): string { return `v1/publications/${publicationId}/cleanup/`; }
|
|
86
|
+
function cleanupKey(publicationId: string, revision: string): string { return `${cleanupPrefix(publicationId)}${revision}.json`; }
|
|
87
|
+
function privateOriginalKey(appId: string, source: SourcePhoto): string { return `v1/${appId}/${source.photoId}/${source.fileVersionId}/original`; }
|
|
88
|
+
|
|
89
|
+
function json(value: unknown, status = 200): Response {
|
|
90
|
+
return new Response(JSON.stringify(value), { status, headers: {
|
|
91
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
92
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
93
|
+
} });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function failure(status: number, code: string, message: string): Response { return json({ code, error: message }, status); }
|
|
97
|
+
|
|
98
|
+
async function parseBody(request: Request): Promise<ReviewRequest | null> {
|
|
99
|
+
if (!request.body) return null;
|
|
100
|
+
const bytes = await readBodyCapped(request.body, MAX_BODY);
|
|
101
|
+
if (!bytes) return null;
|
|
102
|
+
let value: unknown;
|
|
103
|
+
try { value = JSON.parse(new TextDecoder().decode(bytes)); } catch { return null; }
|
|
104
|
+
if (!value || typeof value !== 'object') return null;
|
|
105
|
+
const input = value as Partial<ReviewRequest>;
|
|
106
|
+
if (typeof input.title !== 'string' || !input.title.trim() || input.title.length > 120
|
|
107
|
+
|| typeof input.description !== 'string' || input.description.length > 500
|
|
108
|
+
|| !Array.isArray(input.photos) || input.photos.length < 1 || input.photos.length > MAX_PHOTOS) return null;
|
|
109
|
+
const ids = new Set<string>();
|
|
110
|
+
for (const photo of input.photos) {
|
|
111
|
+
if (!photo || !UUID.test(photo.photoId) || !UUID.test(photo.fileVersionId) || ids.has(photo.photoId)
|
|
112
|
+
|| typeof photo.caption !== 'string' || photo.caption.length > 500
|
|
113
|
+
|| typeof photo.alt !== 'string' || photo.alt.length > 500
|
|
114
|
+
|| !Number.isSafeInteger(photo.width) || photo.width <= 0
|
|
115
|
+
|| !Number.isSafeInteger(photo.height) || photo.height <= 0
|
|
116
|
+
|| !validPhotoDetails(sharedDetails(photo))) return null;
|
|
117
|
+
ids.add(photo.photoId);
|
|
118
|
+
}
|
|
119
|
+
return { title: input.title.trim(), description: input.description.trim(), photos: input.photos.map((photo) => ({
|
|
120
|
+
photoId: photo.photoId, fileVersionId: photo.fileVersionId, caption: photo.caption.trim(),
|
|
121
|
+
alt: photo.alt.trim(), width: photo.width, height: photo.height, ...sharedDetails(photo),
|
|
122
|
+
})) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function digest(value: string): Promise<string> {
|
|
126
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
127
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readJson<T>(bucket: R2Bucket, key: string): Promise<T | null> {
|
|
131
|
+
const object = await bucket.get(key);
|
|
132
|
+
if (!object) return null;
|
|
133
|
+
try { return await object.json() as T; } catch { return null; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function getOwner(env: PublicationEnv, publicationId: string, appId: string, logId: string): Promise<Owner | null> {
|
|
137
|
+
const owner = await readJson<Owner>(env.GALLERY_FILES, ownerKey(publicationId));
|
|
138
|
+
return owner?.appId === appId && owner.logId === logId ? owner : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function ensureOwner(env: PublicationEnv, publicationId: string, appId: string, logId: string): Promise<boolean> {
|
|
142
|
+
const key = ownerKey(publicationId);
|
|
143
|
+
if (!await env.GALLERY_FILES.head(key)) {
|
|
144
|
+
await env.GALLERY_FILES.put(key, JSON.stringify({ appId, logId } satisfies Owner), {
|
|
145
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
146
|
+
httpMetadata: { contentType: 'application/json' },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return Boolean(await getOwner(env, publicationId, appId, logId));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function draft(env: PublicationEnv, publicationId: string, revision: string): Promise<Draft | null> {
|
|
153
|
+
const value = await readJson<Draft>(env.GALLERY_FILES, draftKey(publicationId, revision));
|
|
154
|
+
if (value?.version !== 1 || !Array.isArray(value.sources) || !parsePublicManifest(value.manifest)) return null;
|
|
155
|
+
if (value.sources.length !== value.manifest.photos.length) return null;
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function publicRevision(env: StorageEnv, publicationId: string): Promise<string | null> {
|
|
160
|
+
const pointer = await readJson<{ revision?: unknown }>(env.GALLERY_PUBLIC, activeKey(publicationId));
|
|
161
|
+
return pointer && typeof pointer.revision === 'string' && UUID.test(pointer.revision) ? pointer.revision : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Retired revisions whose public files still await deletion. */
|
|
165
|
+
async function cleanupRevisions(env: Pick<StorageEnv, 'GALLERY_FILES'>, publicationId: string): Promise<string[]> {
|
|
166
|
+
const pending: string[] = [];
|
|
167
|
+
let cursor: string | undefined;
|
|
168
|
+
do {
|
|
169
|
+
const page = await env.GALLERY_FILES.list({ prefix: cleanupPrefix(publicationId), limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
170
|
+
for (const object of page.objects) {
|
|
171
|
+
const revision = object.key.slice(cleanupPrefix(publicationId).length).replace(/\.json$/, '');
|
|
172
|
+
if (UUID.test(revision)) pending.push(revision);
|
|
173
|
+
}
|
|
174
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
175
|
+
} while (cursor);
|
|
176
|
+
return pending;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function status(env: PublicationEnv, publicationId: string): Promise<Response> {
|
|
180
|
+
const activeRevision = await publicRevision(env, publicationId);
|
|
181
|
+
const pending = await cleanupRevisions(env, publicationId);
|
|
182
|
+
return json({ publicationId, activeRevision, publicUrl: `${env.GALLERY_PUBLIC_ORIGIN}/g/${publicationId}`, cleanupRevisions: pending });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function queueCleanup(env: StorageEnv, publicationId: string, revision: string | null): Promise<boolean> {
|
|
186
|
+
if (!revision) return false;
|
|
187
|
+
const key = cleanupKey(publicationId, revision);
|
|
188
|
+
if (await env.GALLERY_FILES.head(key)) return false;
|
|
189
|
+
const stored = await env.GALLERY_FILES.put(key, JSON.stringify({ revision }), {
|
|
190
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
191
|
+
httpMetadata: { contentType: 'application/json' },
|
|
192
|
+
});
|
|
193
|
+
return Boolean(stored);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function reviewedRevision(env: PublicationEnv, publicationId: string, revision: string): Promise<Response> {
|
|
197
|
+
const current = await draft(env, publicationId, revision);
|
|
198
|
+
if (!current) return failure(404, 'revision_not_found', 'Publication revision not found');
|
|
199
|
+
return json({
|
|
200
|
+
title: current.manifest.title,
|
|
201
|
+
description: current.manifest.description,
|
|
202
|
+
photos: current.sources.map((source, index) => ({
|
|
203
|
+
photoId: source.photoId,
|
|
204
|
+
fileVersionId: source.fileVersionId,
|
|
205
|
+
caption: current.manifest.photos[index].caption,
|
|
206
|
+
alt: current.manifest.photos[index].alt,
|
|
207
|
+
width: current.manifest.photos[index].width,
|
|
208
|
+
height: current.manifest.photos[index].height,
|
|
209
|
+
...sharedDetails(current.manifest.photos[index]),
|
|
210
|
+
})),
|
|
211
|
+
} satisfies ReviewRequest);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Asset IDs of the live revision by source photo and file version. A photo
|
|
216
|
+
* that is unchanged keeps its ID, so its address survives a republish.
|
|
217
|
+
*/
|
|
218
|
+
async function publishedAssetIds(env: PublicationEnv, publicationId: string): Promise<Map<string, string>> {
|
|
219
|
+
const revision = await publicRevision(env, publicationId);
|
|
220
|
+
const live = revision ? await draft(env, publicationId, revision) : null;
|
|
221
|
+
return new Map((live?.sources ?? []).map((source) => [`${source.photoId}:${source.fileVersionId}`, source.assetId]));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function putDraft(request: Request, env: PublicationEnv, appId: string, logId: string, publicationId: string, revision: string): Promise<Response> {
|
|
225
|
+
const review = await parseBody(request).catch(() => null);
|
|
226
|
+
if (!review) return failure(400, 'invalid_review', 'Review title, photos, and public captions are required');
|
|
227
|
+
const requestHash = await digest(JSON.stringify(review));
|
|
228
|
+
const key = draftKey(publicationId, revision);
|
|
229
|
+
const existing = await draft(env, publicationId, revision);
|
|
230
|
+
if (existing) {
|
|
231
|
+
if (!await getOwner(env, publicationId, appId, logId)) return failure(404, 'publication_not_found', 'Publication not found');
|
|
232
|
+
return existing.requestHash === requestHash
|
|
233
|
+
? json({ publicationId, revision, chunks: Math.ceil(existing.sources.length / CHUNK_SIZE) })
|
|
234
|
+
: failure(409, 'revision_conflict', 'This revision was already reviewed with different content');
|
|
235
|
+
}
|
|
236
|
+
if (!await ensureOwner(env, publicationId, appId, logId)) return failure(404, 'publication_not_found', 'Publication not found');
|
|
237
|
+
const pointer = await env.GALLERY_PUBLIC.head(activeKey(publicationId));
|
|
238
|
+
const earlier = await publishedAssetIds(env, publicationId);
|
|
239
|
+
const sources = review.photos.map((photo) => ({
|
|
240
|
+
photoId: photo.photoId, fileVersionId: photo.fileVersionId,
|
|
241
|
+
assetId: earlier.get(`${photo.photoId}:${photo.fileVersionId}`) ?? crypto.randomUUID(),
|
|
242
|
+
}));
|
|
243
|
+
const manifest: PublicGalleryManifest = {
|
|
244
|
+
version: 1, title: review.title, description: review.description,
|
|
245
|
+
photos: review.photos.map((photo, index) => ({
|
|
246
|
+
assetId: sources[index].assetId, caption: photo.caption, alt: photo.alt,
|
|
247
|
+
width: photo.width, height: photo.height, ...sharedDetails(photo),
|
|
248
|
+
})),
|
|
249
|
+
};
|
|
250
|
+
const value: Draft = { version: 1, requestHash, expectedEtag: pointer?.httpEtag ?? null, sources, manifest };
|
|
251
|
+
const created = await env.GALLERY_FILES.put(key, JSON.stringify(value), {
|
|
252
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
253
|
+
httpMetadata: { contentType: 'application/json' },
|
|
254
|
+
});
|
|
255
|
+
if (!created) {
|
|
256
|
+
const raced = await draft(env, publicationId, revision);
|
|
257
|
+
if (!raced || raced.requestHash !== requestHash) return failure(409, 'revision_conflict', 'This revision was already reviewed with different content');
|
|
258
|
+
}
|
|
259
|
+
return json({ publicationId, revision, chunks: Math.ceil(sources.length / CHUNK_SIZE) }, 201);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function transform(images: ImagesBinding, bytes: Uint8Array, width: number): Promise<ArrayBuffer> {
|
|
263
|
+
const result = await images.input(new Blob([new Uint8Array(bytes)]).stream())
|
|
264
|
+
.transform({ width, fit: 'scale-down' }).output({ format: 'image/webp', quality: 82, anim: false });
|
|
265
|
+
const output = await result.response().arrayBuffer();
|
|
266
|
+
if (output.byteLength === 0 || output.byteLength > 10 * 1024 * 1024) throw new Error('Invalid public image');
|
|
267
|
+
return output;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function processChunk(env: PublicationEnv, appId: string, logId: string, publicationId: string, revision: string, chunk: number): Promise<Response> {
|
|
271
|
+
const current = await draft(env, publicationId, revision);
|
|
272
|
+
if (!current) return failure(404, 'revision_not_found', 'Publication revision not found');
|
|
273
|
+
const chunks = Math.ceil(current.sources.length / CHUNK_SIZE);
|
|
274
|
+
if (chunk >= chunks) return failure(404, 'chunk_not_found', 'Publication chunk not found');
|
|
275
|
+
const key = markerKey(publicationId, revision, chunk);
|
|
276
|
+
const sources = current.sources.slice(chunk * CHUNK_SIZE, (chunk + 1) * CHUNK_SIZE);
|
|
277
|
+
if (await env.GALLERY_FILES.head(key)) {
|
|
278
|
+
const present = await Promise.all(sources.flatMap((source) => [
|
|
279
|
+
env.GALLERY_PUBLIC.head(assetKey(publicationId, revision, source.assetId, 'thumbnail')),
|
|
280
|
+
env.GALLERY_PUBLIC.head(assetKey(publicationId, revision, source.assetId, 'display')),
|
|
281
|
+
]));
|
|
282
|
+
if (present.every(Boolean)) return json({ chunk, chunks, complete: true });
|
|
283
|
+
}
|
|
284
|
+
for (const source of sources) {
|
|
285
|
+
try { await verifyGalleryPhotoReference(logId, source.photoId, source.fileVersionId, env); }
|
|
286
|
+
catch (cause) {
|
|
287
|
+
if (cause instanceof LedgerScopeError) return failure(cause.status, cause.code, cause.message);
|
|
288
|
+
return failure(503, 'reference_unavailable', 'Source photo could not be verified');
|
|
289
|
+
}
|
|
290
|
+
const original = await env.GALLERY_FILES.get(privateOriginalKey(appId, source));
|
|
291
|
+
if (!original) return failure(409, 'source_not_uploaded', 'Upload every selected original before publishing');
|
|
292
|
+
const bytes = new Uint8Array(await original.arrayBuffer());
|
|
293
|
+
for (const [variant, width] of [['thumbnail', 640], ['display', 2400]] as const) {
|
|
294
|
+
const path = assetKey(publicationId, revision, source.assetId, variant);
|
|
295
|
+
if (!await env.GALLERY_PUBLIC.head(path)) {
|
|
296
|
+
let output: ArrayBuffer;
|
|
297
|
+
try { output = await transform(env.IMAGES, bytes, width); }
|
|
298
|
+
catch { return failure(503, 'public_image_failed', 'A public image could not be prepared'); }
|
|
299
|
+
await env.GALLERY_PUBLIC.put(path, output, {
|
|
300
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
301
|
+
httpMetadata: { contentType: 'image/webp' },
|
|
302
|
+
});
|
|
303
|
+
if (!await env.GALLERY_PUBLIC.head(path)) return failure(503, 'public_image_unavailable', 'A public image could not be stored');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
await env.GALLERY_FILES.put(key, JSON.stringify({ complete: true }), {
|
|
308
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
309
|
+
httpMetadata: { contentType: 'application/json' },
|
|
310
|
+
});
|
|
311
|
+
return json({ chunk, chunks, complete: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function allChunksComplete(env: PublicationEnv, publicationId: string, revision: string, count: number): Promise<boolean> {
|
|
315
|
+
const prefix = markerPrefix(publicationId, revision);
|
|
316
|
+
const seen = new Set<number>();
|
|
317
|
+
let cursor: string | undefined;
|
|
318
|
+
do {
|
|
319
|
+
const page = await env.GALLERY_FILES.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
320
|
+
for (const object of page.objects) {
|
|
321
|
+
const match = object.key.slice(prefix.length).match(/^(0|[1-9][0-9]*)\.json$/);
|
|
322
|
+
if (match) seen.add(Number(match[1]));
|
|
323
|
+
}
|
|
324
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
325
|
+
} while (cursor);
|
|
326
|
+
return seen.size === count && Array.from({ length: count }, (_, index) => seen.has(index)).every(Boolean);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function allPublicAssetsPresent(env: PublicationEnv, publicationId: string, revision: string, current: Draft): Promise<boolean> {
|
|
330
|
+
const prefix = `v1/galleries/${publicationId}/revisions/${revision}/assets/`;
|
|
331
|
+
const expected = new Set(current.sources.flatMap((source) => [
|
|
332
|
+
assetKey(publicationId, revision, source.assetId, 'thumbnail'),
|
|
333
|
+
assetKey(publicationId, revision, source.assetId, 'display'),
|
|
334
|
+
]));
|
|
335
|
+
let cursor: string | undefined;
|
|
336
|
+
do {
|
|
337
|
+
const page = await env.GALLERY_PUBLIC.list({ prefix, limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
338
|
+
for (const object of page.objects) expected.delete(object.key);
|
|
339
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
340
|
+
} while (cursor);
|
|
341
|
+
return expected.size === 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function ensurePublicJson(bucket: R2Bucket, key: string, value: unknown): Promise<boolean> {
|
|
345
|
+
const body = JSON.stringify(value);
|
|
346
|
+
const checksum = await digest(body);
|
|
347
|
+
const existing = await bucket.head(key);
|
|
348
|
+
if (existing) return existing.customMetadata?.sha256 === checksum;
|
|
349
|
+
await bucket.put(key, body, {
|
|
350
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
351
|
+
httpMetadata: { contentType: 'application/json' },
|
|
352
|
+
customMetadata: { sha256: checksum },
|
|
353
|
+
});
|
|
354
|
+
return (await bucket.head(key))?.customMetadata?.sha256 === checksum;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function activate(env: PublicationEnv, publicationId: string, revision: string): Promise<Response> {
|
|
358
|
+
const current = await draft(env, publicationId, revision);
|
|
359
|
+
if (!current) return failure(404, 'revision_not_found', 'Publication revision not found');
|
|
360
|
+
const chunks = Math.ceil(current.sources.length / CHUNK_SIZE);
|
|
361
|
+
if (!await allChunksComplete(env, publicationId, revision, chunks)) {
|
|
362
|
+
return failure(409, 'publication_incomplete', 'Public images are still being prepared');
|
|
363
|
+
}
|
|
364
|
+
if (!await allPublicAssetsPresent(env, publicationId, revision, current)) {
|
|
365
|
+
return failure(409, 'publication_incomplete', 'A public image is missing');
|
|
366
|
+
}
|
|
367
|
+
const months = publicMonths(current.manifest.photos);
|
|
368
|
+
const index: PublicGalleryIndex = {
|
|
369
|
+
version: 2, title: current.manifest.title, description: current.manifest.description,
|
|
370
|
+
photoCount: current.manifest.photos.length, ids: true, ...(months ? { months } : {}),
|
|
371
|
+
};
|
|
372
|
+
for (let page = 0; page < Math.ceil(index.photoCount / 100); page += 1) {
|
|
373
|
+
const shard: PublicGalleryPage = { version: 2, photos: current.manifest.photos.slice(page * 100, (page + 1) * 100) };
|
|
374
|
+
if (!await ensurePublicJson(env.GALLERY_PUBLIC, pageKey(publicationId, revision, page), shard)) {
|
|
375
|
+
return failure(409, 'manifest_conflict', 'A public page differs from the reviewed revision');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const [shard, ids] of publicIdShards(current.manifest.photos)) {
|
|
379
|
+
if (!await ensurePublicJson(env.GALLERY_PUBLIC, idsKey(publicationId, revision, shard), ids)) {
|
|
380
|
+
return failure(409, 'manifest_conflict', 'A public lookup differs from the reviewed revision');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!await ensurePublicJson(env.GALLERY_PUBLIC, indexKey(publicationId, revision), index)) {
|
|
384
|
+
return failure(409, 'manifest_conflict', 'Public manifest differs from the reviewed revision');
|
|
385
|
+
}
|
|
386
|
+
const pointerKey = activeKey(publicationId);
|
|
387
|
+
const pointer = await env.GALLERY_PUBLIC.head(pointerKey);
|
|
388
|
+
const previousRevision = await publicRevision(env, publicationId);
|
|
389
|
+
if (previousRevision === revision) return status(env, publicationId);
|
|
390
|
+
if ((pointer?.httpEtag ?? null) !== current.expectedEtag) {
|
|
391
|
+
return failure(409, 'publication_changed', 'The public gallery changed after this review');
|
|
392
|
+
}
|
|
393
|
+
const queued = await queueCleanup(env, publicationId, previousRevision);
|
|
394
|
+
const written = await env.GALLERY_PUBLIC.put(pointerKey, JSON.stringify({ revision }), {
|
|
395
|
+
onlyIf: new Headers(pointer ? { 'If-Match': pointer.httpEtag } : { 'If-None-Match': '*' }),
|
|
396
|
+
httpMetadata: { contentType: 'application/json' },
|
|
397
|
+
});
|
|
398
|
+
if (!written) {
|
|
399
|
+
if (queued && previousRevision === await publicRevision(env, publicationId)) {
|
|
400
|
+
await env.GALLERY_FILES.delete(cleanupKey(publicationId, previousRevision!));
|
|
401
|
+
}
|
|
402
|
+
return failure(409, 'publication_changed', 'The public gallery changed after this review');
|
|
403
|
+
}
|
|
404
|
+
return status(env, publicationId);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function unpublish(env: PublicationEnv, publicationId: string): Promise<Response> {
|
|
408
|
+
const key = activeKey(publicationId);
|
|
409
|
+
const current = await env.GALLERY_PUBLIC.head(key);
|
|
410
|
+
if (current && await publicRevision(env, publicationId) === null) return status(env, publicationId);
|
|
411
|
+
const previousRevision = await publicRevision(env, publicationId);
|
|
412
|
+
const queued = await queueCleanup(env, publicationId, previousRevision);
|
|
413
|
+
const written = await env.GALLERY_PUBLIC.put(key, JSON.stringify({ revision: null }), {
|
|
414
|
+
onlyIf: new Headers(current ? { 'If-Match': current.httpEtag } : { 'If-None-Match': '*' }),
|
|
415
|
+
httpMetadata: { contentType: 'application/json' },
|
|
416
|
+
});
|
|
417
|
+
if (!written) {
|
|
418
|
+
if (queued && previousRevision === await publicRevision(env, publicationId)) {
|
|
419
|
+
await env.GALLERY_FILES.delete(cleanupKey(publicationId, previousRevision!));
|
|
420
|
+
}
|
|
421
|
+
return failure(409, 'publication_changed', 'The public gallery changed; refresh and retry');
|
|
422
|
+
}
|
|
423
|
+
return status(env, publicationId);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function cleanupRevision(env: StorageEnv, publicationId: string, revision: string): Promise<Response> {
|
|
427
|
+
const marker = cleanupKey(publicationId, revision);
|
|
428
|
+
if (!await env.GALLERY_FILES.head(marker)) return failure(404, 'cleanup_not_found', 'No retired revision needs cleanup');
|
|
429
|
+
const pointerKey = activeKey(publicationId);
|
|
430
|
+
const pointer = await env.GALLERY_PUBLIC.head(pointerKey);
|
|
431
|
+
if (!pointer) return failure(409, 'publication_changed', 'Refresh the public gallery status before cleanup');
|
|
432
|
+
const activeRevision = await publicRevision(env, publicationId);
|
|
433
|
+
if (activeRevision === revision) return failure(409, 'revision_active', 'An active public revision cannot be cleaned');
|
|
434
|
+
// A conditional rewrite is a barrier against a stale activation racing deletion.
|
|
435
|
+
const barrier = await env.GALLERY_PUBLIC.put(pointerKey, JSON.stringify({ revision: activeRevision }), {
|
|
436
|
+
onlyIf: new Headers({ 'If-Match': pointer.httpEtag }),
|
|
437
|
+
httpMetadata: { contentType: 'application/json' },
|
|
438
|
+
});
|
|
439
|
+
if (!barrier) return failure(409, 'publication_changed', 'The public gallery changed; retry cleanup');
|
|
440
|
+
const prefix = `v1/galleries/${publicationId}/revisions/${revision}/`;
|
|
441
|
+
const page = await env.GALLERY_PUBLIC.list({ prefix, limit: 1000 });
|
|
442
|
+
if (page.objects.length) await env.GALLERY_PUBLIC.delete(page.objects.map((object) => object.key));
|
|
443
|
+
const remaining = page.truncated || page.objects.length === 1000;
|
|
444
|
+
if (!remaining) {
|
|
445
|
+
await deleteChunkMarkers(env, publicationId, revision);
|
|
446
|
+
await env.GALLERY_FILES.delete(marker);
|
|
447
|
+
}
|
|
448
|
+
return json({ revision, deleted: page.objects.length, remaining });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A retired revision cannot resume, so its per-chunk progress markers have no further use. */
|
|
452
|
+
async function deleteChunkMarkers(env: Pick<StorageEnv, 'GALLERY_FILES'>, publicationId: string, revision: string): Promise<void> {
|
|
453
|
+
for (;;) {
|
|
454
|
+
const page = await env.GALLERY_FILES.list({ prefix: markerPrefix(publicationId, revision), limit: 1000 });
|
|
455
|
+
if (page.objects.length === 0) return;
|
|
456
|
+
await env.GALLERY_FILES.delete(page.objects.map((object) => object.key));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Deletes a retired revision's public files in batches, as the browser does. */
|
|
461
|
+
async function drainCleanup(env: StorageEnv, publicationId: string, revision: string): Promise<void> {
|
|
462
|
+
for (let batch = 0; batch < 100; batch += 1) {
|
|
463
|
+
const result = await cleanupRevision(env, publicationId, revision);
|
|
464
|
+
if (!result.ok || !(await result.json() as { remaining?: boolean }).remaining) return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** True when nothing under a revision's public prefix was written for the abandoned period. */
|
|
469
|
+
async function isAbandoned(env: StorageEnv, revisionPrefix: string, now: number): Promise<boolean> {
|
|
470
|
+
const page = await env.GALLERY_PUBLIC.list({ prefix: revisionPrefix, limit: 1000 });
|
|
471
|
+
if (page.objects.length === 0) return false;
|
|
472
|
+
return now - Math.max(...page.objects.map((object) => object.uploaded.getTime())) > ABANDONED_AFTER_MS;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Puts an abandoned revision in the cleanup queue, behind a pointer a stale activation cannot beat. */
|
|
476
|
+
async function retireAbandoned(env: StorageEnv, publicationId: string, revision: string): Promise<boolean> {
|
|
477
|
+
const pointerKey = activeKey(publicationId);
|
|
478
|
+
if (!await env.GALLERY_PUBLIC.head(pointerKey)) {
|
|
479
|
+
// A first publish that never finished: tombstone first, so cleanup has a pointer to guard and the stale review cannot activate.
|
|
480
|
+
await env.GALLERY_PUBLIC.put(pointerKey, JSON.stringify({ revision: null }), {
|
|
481
|
+
onlyIf: new Headers({ 'If-None-Match': '*' }),
|
|
482
|
+
httpMetadata: { contentType: 'application/json' },
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
await queueCleanup(env, publicationId, revision);
|
|
486
|
+
return Boolean(await env.GALLERY_FILES.head(cleanupKey(publicationId, revision)));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function sweepPublication(env: StorageEnv, publicationId: string, now: number): Promise<number> {
|
|
490
|
+
const queued = await cleanupRevisions(env, publicationId);
|
|
491
|
+
for (const revision of queued) await drainCleanup(env, publicationId, revision);
|
|
492
|
+
const active = await publicRevision(env, publicationId);
|
|
493
|
+
const skipped = new Set([...queued, ...(active ? [active] : [])]);
|
|
494
|
+
const prefix = `v1/galleries/${publicationId}/revisions/`;
|
|
495
|
+
let swept = queued.length;
|
|
496
|
+
let cursor: string | undefined;
|
|
497
|
+
do {
|
|
498
|
+
const page = await env.GALLERY_PUBLIC.list({ prefix, delimiter: '/', limit: 1000, ...(cursor ? { cursor } : {}) });
|
|
499
|
+
for (const directory of page.delimitedPrefixes) {
|
|
500
|
+
const revision = directory.slice(prefix.length, -1);
|
|
501
|
+
if (!UUID.test(revision) || skipped.has(revision) || !await isAbandoned(env, directory, now)) continue;
|
|
502
|
+
if (!await retireAbandoned(env, publicationId, revision)) continue;
|
|
503
|
+
await drainCleanup(env, publicationId, revision);
|
|
504
|
+
swept += 1;
|
|
505
|
+
}
|
|
506
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
507
|
+
} while (cursor);
|
|
508
|
+
return swept;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Scheduled housekeeping: finishes interrupted cleanups and removes public files
|
|
513
|
+
* of revisions that were reviewed and never activated. Each run takes a bounded
|
|
514
|
+
* share of the publications and the next run continues after it.
|
|
515
|
+
*/
|
|
516
|
+
export async function sweepGalleryPublications(env: StorageEnv, now = Date.now()): Promise<{ publications: number; revisions: number }> {
|
|
517
|
+
const stored = await readJson<{ after?: unknown }>(env.GALLERY_FILES, SWEEP_CURSOR_KEY);
|
|
518
|
+
const after = typeof stored?.after === 'string' && UUID.test(stored.after) ? stored.after : null;
|
|
519
|
+
// '0' sorts after '/', so this skips every key of the publication that ended the last run.
|
|
520
|
+
const page = await env.GALLERY_FILES.list({
|
|
521
|
+
prefix: PUBLICATIONS_PREFIX, delimiter: '/', limit: SWEEP_PUBLICATIONS_PER_RUN,
|
|
522
|
+
...(after ? { startAfter: `${PUBLICATIONS_PREFIX}${after}0` } : {}),
|
|
523
|
+
});
|
|
524
|
+
const ids = page.delimitedPrefixes.map((directory) => directory.slice(PUBLICATIONS_PREFIX.length, -1)).filter((id) => UUID.test(id));
|
|
525
|
+
let revisions = 0;
|
|
526
|
+
for (const publicationId of ids) revisions += await sweepPublication(env, publicationId, now);
|
|
527
|
+
const finished = !page.truncated;
|
|
528
|
+
await env.GALLERY_FILES.put(SWEEP_CURSOR_KEY, JSON.stringify(finished || ids.length === 0 ? {} : { after: ids[ids.length - 1] }), {
|
|
529
|
+
httpMetadata: { contentType: 'application/json' },
|
|
530
|
+
});
|
|
531
|
+
return { publications: ids.length, revisions };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function handleGalleryPublicationRequest(
|
|
535
|
+
request: Request,
|
|
536
|
+
env: PublicationEnv,
|
|
537
|
+
authorize: Authorizer = authorizeStorRequest,
|
|
538
|
+
): Promise<Response> {
|
|
539
|
+
const url = new URL(request.url);
|
|
540
|
+
const match = url.pathname.match(ROOT) ?? url.pathname.match(REVISION) ?? url.pathname.match(CHUNK)
|
|
541
|
+
?? url.pathname.match(ACTIVATE) ?? url.pathname.match(CLEANUP) ?? url.pathname.match(UNPUBLISH);
|
|
542
|
+
if (!match) return failure(404, 'route_not_found', 'Publication route not found');
|
|
543
|
+
const [, logId, publicationId] = match;
|
|
544
|
+
const revision = url.pathname.match(REVISION)?.[3] ?? url.pathname.match(CHUNK)?.[3] ?? url.pathname.match(ACTIVATE)?.[3] ?? url.pathname.match(CLEANUP)?.[3];
|
|
545
|
+
const chunk = url.pathname.match(CHUNK)?.[4];
|
|
546
|
+
if (!env.GALLERY_FILES || !env.GALLERY_PUBLIC || !env.IMAGES || !env.LEDGER_AUTH_TOKEN || !env.GALLERY_PUBLIC_ORIGIN) {
|
|
547
|
+
return failure(503, 'publication_unconfigured', 'Gallery publishing is not configured');
|
|
548
|
+
}
|
|
549
|
+
if (request.headers.get('Origin') && request.headers.get('Origin') !== url.origin) {
|
|
550
|
+
return failure(403, 'origin_not_allowed', 'Cross-origin publication requests are not allowed');
|
|
551
|
+
}
|
|
552
|
+
try { await authorize(request, env); }
|
|
553
|
+
catch (cause) {
|
|
554
|
+
return cause instanceof LedgerAdminConfigurationError
|
|
555
|
+
? failure(503, 'publication_unconfigured', 'Gallery publishing is not configured')
|
|
556
|
+
: failure(403, 'publication_forbidden', 'Hearthlog access is required');
|
|
557
|
+
}
|
|
558
|
+
let appId: string;
|
|
559
|
+
try { appId = await verifyLogAppId(logId, env); }
|
|
560
|
+
catch (cause) {
|
|
561
|
+
return cause instanceof LedgerScopeError
|
|
562
|
+
? failure(cause.status, cause.code, cause.message)
|
|
563
|
+
: failure(503, 'ledger_verification_failed', 'Source log could not be verified');
|
|
564
|
+
}
|
|
565
|
+
if (request.method === 'PUT' && url.pathname.match(REVISION) && revision) {
|
|
566
|
+
return putDraft(request, env, appId, logId, publicationId, revision);
|
|
567
|
+
}
|
|
568
|
+
if (!await getOwner(env, publicationId, appId, logId)) return failure(404, 'publication_not_found', 'Publication not found');
|
|
569
|
+
if (request.method === 'GET' && url.pathname.match(ROOT)) return status(env, publicationId);
|
|
570
|
+
if (request.method === 'GET' && url.pathname.match(REVISION) && revision) return reviewedRevision(env, publicationId, revision);
|
|
571
|
+
if (request.method === 'POST' && chunk !== undefined && revision) return processChunk(env, appId, logId, publicationId, revision, Number(chunk));
|
|
572
|
+
if (request.method === 'POST' && url.pathname.match(ACTIVATE) && revision) return activate(env, publicationId, revision);
|
|
573
|
+
if (request.method === 'POST' && url.pathname.match(CLEANUP) && revision) return cleanupRevision(env, publicationId, revision);
|
|
574
|
+
if (request.method === 'POST' && url.pathname.match(UNPUBLISH)) return unpublish(env, publicationId);
|
|
575
|
+
return failure(404, 'route_not_found', 'Publication route not found');
|
|
576
|
+
}
|
package/src/startup.ts
ADDED