@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.
Files changed (48) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +11 -2
  3. package/hearthlog.module.json +41 -0
  4. package/package.json +42 -3
  5. package/public/app/App.svelte +448 -0
  6. package/public/app/ambient.d.ts +5 -0
  7. package/public/app/client.ts +7 -0
  8. package/public/app/types.ts +53 -0
  9. package/public/client-assets.ts +17 -0
  10. package/public/contract.ts +211 -0
  11. package/public/index.ts +237 -0
  12. package/public/tsconfig.json +21 -0
  13. package/src/components/gallery/AlbumCard.svelte +41 -0
  14. package/src/components/gallery/AlbumSection.svelte +323 -0
  15. package/src/components/gallery/GalleryMonthBlock.svelte +159 -0
  16. package/src/components/gallery/GalleryTimeline.svelte +294 -0
  17. package/src/components/gallery/PhotoThumb.svelte +26 -0
  18. package/src/components/gallery/PhotoTile.svelte +163 -0
  19. package/src/components/gallery/PhotoViewer.svelte +219 -0
  20. package/src/components/gallery/PublicationManager.svelte +386 -0
  21. package/src/components/gallery/PublicationPreviewPhoto.svelte +33 -0
  22. package/src/components/gallery/gallery-blocks.svelte.ts +30 -0
  23. package/src/components/gallery/view/GallerySpine.svelte +239 -0
  24. package/src/components/gallery/view/GalleryTile.svelte +157 -0
  25. package/src/components/gallery/view/JustifiedRows.svelte +54 -0
  26. package/src/components/gallery/view/MonthHeading.svelte +53 -0
  27. package/src/components/gallery/view/ViewerFrame.svelte +362 -0
  28. package/src/data/definitions.ts +93 -0
  29. package/src/data/gallery.ts +413 -0
  30. package/src/data/repositories.ts +8 -0
  31. package/src/gallery/backup-archive.ts +195 -0
  32. package/src/gallery/backup.ts +140 -0
  33. package/src/gallery/exif.ts +240 -0
  34. package/src/gallery/file-store.ts +206 -0
  35. package/src/gallery/format.ts +101 -0
  36. package/src/gallery/interaction.ts +6 -0
  37. package/src/gallery/layout.ts +67 -0
  38. package/src/gallery/public-details.ts +73 -0
  39. package/src/gallery/publication-api.ts +68 -0
  40. package/src/gallery/selection.ts +25 -0
  41. package/src/gallery/timeline.ts +77 -0
  42. package/src/pages/gallery.svelte +737 -0
  43. package/src/pages/gallery.ts +20 -0
  44. package/src/server/files.ts +229 -0
  45. package/src/server/index.ts +18 -0
  46. package/src/server/ledger.ts +42 -0
  47. package/src/server/publications.ts +576 -0
  48. package/src/startup.ts +2 -0
@@ -0,0 +1,20 @@
1
+ import type { ModuleLoadEvent } from '@hearthlog/sdk/runtime';
2
+ import { deferPage } from '@hearthlog/sdk/runtime';
3
+ import { queryDatabase } from '@hearthlog/sdk/data';
4
+ import { requireActiveContext } from '@hearthlog/sdk/runtime';
5
+ import type { GallerySummary } from '../data/gallery.js';
6
+
7
+ export const load = (event: ModuleLoadEvent) => {
8
+ event.depends('app:gallery');
9
+ const query = event.url.searchParams.get('q') ?? '';
10
+ const favoritesOnly = event.url.searchParams.get('tab') === 'favorites';
11
+ return deferPage(event, {
12
+ summary: { blocks: [], months: [], count: 0 } as GallerySummary, tenantId: '', logId: '',
13
+ }, async () => {
14
+ const [context, summary] = await Promise.all([
15
+ requireActiveContext(),
16
+ queryDatabase((repositories) => repositories.gallery.summary({ query, favoritesOnly })),
17
+ ]);
18
+ return { summary, tenantId: context.tenantId, logId: context.logId };
19
+ });
20
+ };
@@ -0,0 +1,229 @@
1
+ import {
2
+ authorizeStorRequest,
3
+ LedgerAdminConfigurationError,
4
+ type LedgerAdminIdentity,
5
+ } from '@hearthlog/sdk/server';
6
+ import {
7
+ LedgerScopeError,
8
+ verifyLogAppId,
9
+ } from '@hearthlog/sdk/server';
10
+ import { readBodyCapped } from '@hearthlog/sdk/server';
11
+ import { verifyGalleryPhotoReference } from './ledger.js';
12
+
13
+ const PREFIX = '/api/gallery-files/v1';
14
+ const UUID = '[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}';
15
+ const ROUTE = new RegExp(`^${PREFIX}/logs/(${UUID})/photos/(${UUID})/versions/(${UUID})/(original|thumbnail|display)$`);
16
+ const MAX_BYTES = 20 * 1024 * 1024;
17
+ const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
18
+ const DERIVATIVE_WIDTHS = { thumbnail: 480, display: 2048 } as const;
19
+ type Derivative = keyof typeof DERIVATIVE_WIDTHS;
20
+
21
+ export type GalleryFileEnv = Pick<Env,
22
+ 'CF_ACCESS_APP_AUD' | 'CF_ACCESS_TEAM_DOMAIN' | 'STOR_AUTHORIZED_EMAILS' |
23
+ 'LEDGER' | 'LEDGER_AUTH_TOKEN' | 'GALLERY_FILES' | 'IMAGES'
24
+ >;
25
+
26
+ type Authorizer = (request: Request, env: GalleryFileEnv) => Promise<LedgerAdminIdentity>;
27
+
28
+ export async function handleGalleryFileRequest(
29
+ request: Request,
30
+ env: GalleryFileEnv,
31
+ authorize: Authorizer = authorizeStorRequest,
32
+ ): Promise<Response> {
33
+ const url = new URL(request.url);
34
+ const match = url.pathname.match(ROUTE);
35
+ if (!match || !['PUT', 'GET', 'HEAD'].includes(request.method)) {
36
+ return error(404, 'gallery_file_route_not_found', 'Gallery file route not found');
37
+ }
38
+ if (!env.LEDGER_AUTH_TOKEN || !env.GALLERY_FILES) {
39
+ return error(503, 'gallery_files_unconfigured', 'Gallery files are not configured');
40
+ }
41
+ const origin = request.headers.get('Origin');
42
+ if (origin !== null && origin !== url.origin) {
43
+ return error(403, 'origin_not_allowed', 'Cross-origin file requests are not allowed');
44
+ }
45
+ try {
46
+ await authorize(request, env);
47
+ } catch (cause) {
48
+ return cause instanceof LedgerAdminConfigurationError
49
+ ? error(503, 'gallery_files_unconfigured', 'Gallery files are not configured')
50
+ : error(403, 'gallery_files_forbidden', 'Hearthlog access is required');
51
+ }
52
+
53
+ const [, logId, photoId, fileVersionId, variant] = match;
54
+ let appId: string;
55
+ try {
56
+ appId = await verifyLogAppId(logId, env);
57
+ await verifyGalleryPhotoReference(logId, photoId, fileVersionId, env);
58
+ } catch (cause) {
59
+ if (cause instanceof LedgerScopeError) return error(cause.status, cause.code, cause.message);
60
+ return error(503, 'ledger_verification_failed', 'The photo reference could not be verified');
61
+ }
62
+ const key = `v1/${appId}/${photoId}/${fileVersionId}/${variant}`;
63
+ if (request.method === 'PUT') {
64
+ if (variant !== 'original') return error(405, 'method_not_allowed', 'Only originals can be uploaded');
65
+ if (!env.IMAGES) return error(503, 'gallery_images_unconfigured', 'Gallery image processing is unavailable');
66
+ return putOriginal(request, env.GALLERY_FILES, env.IMAGES, key);
67
+ }
68
+ if (variant !== 'original' && env.IMAGES) {
69
+ const repaired = await repairDerivative(env.GALLERY_FILES, env.IMAGES, key, variant as Derivative);
70
+ if (repaired) return repaired;
71
+ }
72
+ return getFile(request, env.GALLERY_FILES, key);
73
+ }
74
+
75
+ function variantKey(key: string, variant: 'original' | Derivative): string {
76
+ return key.replace(/\/(original|thumbnail|display)$/, `/${variant}`);
77
+ }
78
+
79
+ /**
80
+ * Rebuild a missing derivative from its stored original, so a failed or
81
+ * misplaced derivative write never leaves other devices without a preview.
82
+ */
83
+ async function repairDerivative(
84
+ bucket: R2Bucket,
85
+ images: ImagesBinding,
86
+ key: string,
87
+ variant: Derivative,
88
+ ): Promise<Response | null> {
89
+ if (await bucket.head(key)) return null;
90
+ const original = await bucket.get(variantKey(key, 'original'));
91
+ if (!original) return null;
92
+ try {
93
+ const bytes = new Uint8Array(await original.arrayBuffer());
94
+ await ensureDerivative(bucket, images, key, bytes, DERIVATIVE_WIDTHS[variant]);
95
+ } catch {
96
+ return error(503, 'gallery_derivatives_unavailable', 'The display image could not be prepared yet');
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function putOriginal(request: Request, bucket: R2Bucket, images: ImagesBinding, key: string): Promise<Response> {
102
+ const contentType = request.headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase() ?? '';
103
+ if (!IMAGE_TYPES.has(contentType)) {
104
+ return error(415, 'unsupported_image_type', 'Use a JPEG, PNG, WebP, HEIC, or HEIF photo');
105
+ }
106
+ const declaredSize = request.headers.get('Content-Length');
107
+ if (declaredSize !== null && (!Number.isSafeInteger(Number(declaredSize)) || Number(declaredSize) <= 0)) {
108
+ return error(400, 'invalid_content_length', 'Content-Length must be a positive integer');
109
+ }
110
+ if (declaredSize !== null && Number(declaredSize) > MAX_BYTES) {
111
+ return error(413, 'image_too_large', 'Photos must be 20 MiB or smaller');
112
+ }
113
+ if (!request.body) return error(400, 'image_body_required', 'A photo body is required');
114
+
115
+ let bytes: Uint8Array;
116
+ try {
117
+ const capped = await readBodyCapped(request.body, MAX_BYTES);
118
+ if (!capped) return error(413, 'image_too_large', 'Photos must be 20 MiB or smaller');
119
+ bytes = capped;
120
+ } catch {
121
+ return error(400, 'image_unreadable', 'The photo could not be read');
122
+ }
123
+ if (!matchesImageSignature(bytes, contentType)) {
124
+ return error(415, 'invalid_image', 'The photo bytes do not match the selected format');
125
+ }
126
+ const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer);
127
+ const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
128
+ const existing = await bucket.head(key);
129
+ if (existing && !matchesExisting(existing, sha256, contentType)) {
130
+ return error(409, 'gallery_file_version_conflict', 'This photo version already has different bytes');
131
+ }
132
+ let object = existing;
133
+ if (!object) {
134
+ object = await bucket.put(key, bytes, {
135
+ onlyIf: new Headers({ 'If-None-Match': '*' }),
136
+ sha256: digest,
137
+ httpMetadata: { contentType },
138
+ customMetadata: { sha256 },
139
+ });
140
+ if (!object) {
141
+ object = await bucket.head(key);
142
+ if (!object) return error(503, 'gallery_file_unavailable', 'The uploaded photo could not be verified');
143
+ if (!matchesExisting(object, sha256, contentType)) {
144
+ return error(409, 'gallery_file_version_conflict', 'This photo version already has different bytes');
145
+ }
146
+ }
147
+ }
148
+ try {
149
+ for (const variant of ['thumbnail', 'display'] as const) {
150
+ await ensureDerivative(bucket, images, variantKey(key, variant), bytes, DERIVATIVE_WIDTHS[variant]);
151
+ }
152
+ } catch (cause) {
153
+ if (cause && typeof cause === 'object' && 'code' in cause && cause.code === 9412) {
154
+ return error(415, 'invalid_image', 'The photo could not be decoded');
155
+ }
156
+ return error(503, 'gallery_derivatives_unavailable', 'The original was stored, but display images need a retry');
157
+ }
158
+ return json({ size: object.size, sha256, contentType }, existing ? 200 : 201);
159
+ }
160
+
161
+ function matchesExisting(object: R2Object, sha256: string, contentType: string): boolean {
162
+ return object.customMetadata?.sha256 === sha256 && object.httpMetadata?.contentType === contentType;
163
+ }
164
+
165
+ async function ensureDerivative(
166
+ bucket: R2Bucket,
167
+ images: ImagesBinding,
168
+ key: string,
169
+ original: Uint8Array,
170
+ width: number,
171
+ ): Promise<void> {
172
+ if (await bucket.head(key)) return;
173
+ const input = new Blob([new Uint8Array(original)]).stream();
174
+ const transformed = await images.input(input).transform({ width, fit: 'scale-down' }).output({ format: 'image/webp', quality: 80 });
175
+ const bytes = await transformed.response().arrayBuffer();
176
+ if (bytes.byteLength === 0 || bytes.byteLength > 8 * 1024 * 1024) throw new Error('Invalid Gallery derivative');
177
+ const created = await bucket.put(key, bytes, {
178
+ onlyIf: new Headers({ 'If-None-Match': '*' }),
179
+ httpMetadata: { contentType: 'image/webp' },
180
+ });
181
+ if (!created && !await bucket.head(key)) throw new Error('Gallery derivative was not stored');
182
+ }
183
+
184
+ async function getFile(request: Request, bucket: R2Bucket, key: string): Promise<Response> {
185
+ const object = request.method === 'HEAD' ? await bucket.head(key) : await bucket.get(key);
186
+ if (!object) return error(404, 'gallery_file_not_found', 'Photo bytes are not available');
187
+ const headers = new Headers({
188
+ 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
189
+ 'Content-Length': String(object.size),
190
+ ETag: object.httpEtag,
191
+ 'Content-Disposition': 'inline',
192
+ });
193
+ if (request.headers.get('If-None-Match') === object.httpEtag) {
194
+ return secure(null, { status: 304, headers: { ETag: object.httpEtag } });
195
+ }
196
+ const body = request.method === 'HEAD' ? null : (object as R2ObjectBody).body;
197
+ return secure(body, { headers });
198
+ }
199
+
200
+ export function matchesImageSignature(bytes: Uint8Array, contentType: string): boolean {
201
+ if (contentType === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
202
+ if (contentType === 'image/png') return bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => bytes[index] === byte);
203
+ if (contentType === 'image/webp') return bytes.length >= 12 && ascii(bytes, 0, 'RIFF') && ascii(bytes, 8, 'WEBP');
204
+ if (contentType === 'image/heic' || contentType === 'image/heif') {
205
+ if (bytes.length < 12 || !ascii(bytes, 4, 'ftyp')) return false;
206
+ const brand = String.fromCharCode(...bytes.slice(8, 12));
207
+ return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
208
+ }
209
+ return false;
210
+ }
211
+
212
+ function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
213
+ return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
214
+ }
215
+
216
+ function json(value: unknown, status: number): Response {
217
+ return secure(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
218
+ }
219
+
220
+ function error(status: number, code: string, message: string): Response {
221
+ return json({ error: message, code }, status);
222
+ }
223
+
224
+ function secure(body: BodyInit | null, init: ResponseInit): Response {
225
+ const headers = new Headers(init.headers);
226
+ headers.set('Cache-Control', 'no-store');
227
+ headers.set('X-Content-Type-Options', 'nosniff');
228
+ return new Response(body, { ...init, headers });
229
+ }
@@ -0,0 +1,18 @@
1
+ import type { ServerModule } from '@hearthlog/sdk/server';
2
+ import { handleGalleryFileRequest } from './files.js';
3
+ import { handleGalleryPublicationRequest, sweepGalleryPublications } from './publications.js';
4
+
5
+ /** Gallery's part of the gateway Worker: private photo files, publications, and the daily sweep of public copies. */
6
+ const gallery: ServerModule<Env> = {
7
+ id: 'org.stor.gallery',
8
+ routes: [
9
+ { prefix: '/api/gallery-files/v1', handle: handleGalleryFileRequest },
10
+ { prefix: '/api/gallery-publications/v1', handle: handleGalleryPublicationRequest },
11
+ ],
12
+ async scheduled(env) {
13
+ const result = await sweepGalleryPublications(env);
14
+ console.log('[Gallery] Publication sweep', result);
15
+ },
16
+ };
17
+
18
+ export default gallery;
@@ -0,0 +1,42 @@
1
+ import { LedgerScopeError, type LedgerScopeEnv } from '@hearthlog/sdk/server';
2
+
3
+ /** Check the exact immutable file version against accepted Ledger history. */
4
+ export async function verifyGalleryPhotoReference(
5
+ logId: string,
6
+ photoId: string,
7
+ fileVersionId: string,
8
+ env: LedgerScopeEnv,
9
+ ): Promise<void> {
10
+ let response: Response;
11
+ try {
12
+ response = await env.LEDGER.fetch(new Request(
13
+ `https://ledger.internal/logs/${encodeURIComponent(logId)}/gallery-photos/${encodeURIComponent(photoId)}/files/${encodeURIComponent(fileVersionId)}/reference`,
14
+ { headers: { Authorization: `Bearer ${env.LEDGER_AUTH_TOKEN}` } },
15
+ ));
16
+ } catch {
17
+ throw unavailableError();
18
+ }
19
+ if (response.status === 404) {
20
+ throw new LedgerScopeError(404, 'log_not_found', 'The selected log was not found');
21
+ }
22
+ if (response.status === 409) {
23
+ throw new LedgerScopeError(409, 'fork_preparing', 'The selected fork is preparing');
24
+ }
25
+ if (!response.ok) throw unavailableError();
26
+ let value: unknown;
27
+ try {
28
+ value = await response.json();
29
+ } catch {
30
+ throw unavailableError();
31
+ }
32
+ if (!value || typeof value !== 'object' || !('present' in value) || typeof value.present !== 'boolean') {
33
+ throw unavailableError();
34
+ }
35
+ if (!value.present) {
36
+ throw new LedgerScopeError(404, 'gallery_file_not_referenced', 'Photo version not found in this log');
37
+ }
38
+ }
39
+
40
+ function unavailableError(): LedgerScopeError {
41
+ return new LedgerScopeError(503, 'ledger_verification_failed', 'The selected log could not be verified');
42
+ }