/** * Photos module — community photo commons powered by Immich. * * Per-space isolation: each space gets its own Immich album, with * CRUD gated by the space role system (viewer/member/moderator/admin). */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import type { EncryptIDClaims } from "../../server/auth"; import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; import { filterArrayByVisibility } from "../../shared/membrane"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { photosSchema, photosDocId } from './schemas'; import type { PhotosDoc, SpaceAlbum } from './schemas'; import { immichCreateAlbum, immichGetAlbum, immichSearchInAlbum, immichUploadAsset, immichAddAssetsToAlbum, immichDeleteAssets, immichGetAssetThumbnail, immichGetAssetOriginal, } from './lib/immich-client'; let _syncServer: SyncServer | null = null; const routes = new Hono(); const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online"; // ── Local-first helpers ── function ensurePhotosDoc(space: string): PhotosDoc { const docId = photosDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init photos', (d) => { const init = photosSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── Role helper ── async function requireRole( c: any, minRole: SpaceRoleString, ): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString; space: string } | Response> { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const result = await resolveCallerRole(space, claims); if (!result || !roleAtLeast(result.role, minRole)) { return c.json({ error: "Insufficient permissions" }, 403); } return { claims, role: result.role, space }; } /** Resolve caller role without requiring auth (for membrane filtering on read routes). */ async function resolveOptionalRole(c: any): Promise<{ role: SpaceRoleString; space: string }> { const space = c.req.param("space") || "demo"; let role: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) role = resolved.role; } catch {} } return { role, space }; } // ── Setup routes (admin only) ── routes.get("/api/setup/status", async (c) => { if (!_syncServer) return c.json({ enabled: false, spaceAlbumId: null }); const { role, space } = await resolveOptionalRole(c); const doc = ensurePhotosDoc(space); return c.json({ enabled: doc.enabled, spaceAlbumId: doc.spaceAlbumId }); }); routes.post("/api/setup", async (c) => { const auth = await requireRole(c, "admin"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const doc = ensurePhotosDoc(space); if (doc.enabled && doc.spaceAlbumId) { return c.json({ enabled: true, spaceAlbumId: doc.spaceAlbumId }); } try { const album = await immichCreateAlbum(`rspace:${space}`, `Root album for space ${space}`); const docId = photosDocId(space); _syncServer.changeDoc(docId, `enable rphotos for ${space}`, (d) => { d.enabled = true; d.spaceAlbumId = album.id; }); return c.json({ enabled: true, spaceAlbumId: album.id }, 201); } catch (err: any) { return c.json({ error: `Failed to create Immich album: ${err.message}` }, 500); } }); routes.delete("/api/setup", async (c) => { const auth = await requireRole(c, "admin"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const docId = photosDocId(space); _syncServer.changeDoc(docId, `disable rphotos for ${space}`, (d) => { d.enabled = false; }); return c.json({ ok: true }); }); // ── Upload route (member+) ── routes.post("/api/upload", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const doc = ensurePhotosDoc(space); if (!doc.enabled || !doc.spaceAlbumId) { return c.json({ error: "rPhotos is not enabled for this space" }, 400); } try { const body = await c.req.parseBody(); const file = body['file']; if (!file || !(file instanceof File)) { return c.json({ error: "file required" }, 400); } const formData = new FormData(); formData.append('assetData', file, file.name); formData.append('deviceAssetId', crypto.randomUUID()); formData.append('deviceId', `rspace-${space}`); formData.append('fileCreatedAt', new Date().toISOString()); formData.append('fileModifiedAt', new Date().toISOString()); const asset = await immichUploadAsset(formData); await immichAddAssetsToAlbum(doc.spaceAlbumId, [asset.id]); return c.json({ id: asset.id, status: asset.status }, 201); } catch (err: any) { return c.json({ error: `Upload failed: ${err.message}` }, 500); } }); // ── Delete asset (moderator+) ── routes.delete("/api/assets/:id", async (c) => { const auth = await requireRole(c, "moderator"); if (auth instanceof Response) return auth; const assetId = c.req.param("id"); try { await immichDeleteAssets([assetId]); return c.json({ ok: true }); } catch (err: any) { return c.json({ error: `Delete failed: ${err.message}` }, 500); } }); // ── Sub-album CRUD ── routes.post("/api/space-albums", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space, claims } = auth; const doc = ensurePhotosDoc(space); if (!doc.enabled) return c.json({ error: "rPhotos is not enabled for this space" }, 400); const { name } = await c.req.json(); if (!name) return c.json({ error: "name required" }, 400); try { const immichAlbum = await immichCreateAlbum(`rspace:${space}:${name}`, `Sub-album in space ${space}`); const id = crypto.randomUUID(); const callerDid = (claims.did as string) || claims.sub || ''; const docId = photosDocId(space); _syncServer.changeDoc(docId, `create sub-album ${name}`, (d) => { d.subAlbums[id] = { id, immichAlbumId: immichAlbum.id, name, createdBy: callerDid, createdAt: Date.now(), }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.subAlbums[id], 201); } catch (err: any) { return c.json({ error: `Failed to create album: ${err.message}` }, 500); } }); routes.patch("/api/space-albums/:id", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space, claims, role } = auth; const albumId = c.req.param("id"); const doc = ensurePhotosDoc(space); const album = doc.subAlbums[albumId]; if (!album) return c.json({ error: "Album not found" }, 404); const callerDid = (claims.did as string) || claims.sub || ''; const isOwner = album.createdBy === callerDid; if (!isOwner && !roleAtLeast(role, "moderator")) { return c.json({ error: "Insufficient permissions" }, 403); } const body = await c.req.json(); const docId = photosDocId(space); _syncServer.changeDoc(docId, `update sub-album ${albumId}`, (d) => { if (body.name !== undefined) d.subAlbums[albumId].name = body.name; if (body.visibility !== undefined) d.subAlbums[albumId].visibility = body.visibility; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.subAlbums[albumId]); }); routes.delete("/api/space-albums/:id", async (c) => { const auth = await requireRole(c, "moderator"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const albumId = c.req.param("id"); const doc = ensurePhotosDoc(space); if (!doc.subAlbums[albumId]) return c.json({ error: "Album not found" }, 404); const docId = photosDocId(space); _syncServer.changeDoc(docId, `delete sub-album ${albumId}`, (d) => { delete d.subAlbums[albumId]; }); return c.json({ ok: true }); }); // ── CRUD: Curated Albums ── routes.get("/api/curations", async (c) => { if (!_syncServer) return c.json({ albums: [] }); const { role, space } = await resolveOptionalRole(c); const doc = ensurePhotosDoc(space); return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), role) }); }); routes.post("/api/curations", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space, claims } = auth; const { name, description = "" } = await c.req.json(); if (!name) return c.json({ error: "name required" }, 400); const id = crypto.randomUUID(); const docId = photosDocId(space); ensurePhotosDoc(space); _syncServer.changeDoc(docId, `share album ${id}`, (d) => { d.sharedAlbums[id] = { id, name, description, sharedBy: claims.sub || null, sharedAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.sharedAlbums[id], 201); }); routes.delete("/api/curations/:albumId", async (c) => { const auth = await requireRole(c, "moderator"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const albumId = c.req.param("albumId"); const docId = photosDocId(space); const doc = ensurePhotosDoc(space); if (!doc.sharedAlbums[albumId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `remove album ${albumId}`, (d) => { delete d.sharedAlbums[albumId]; }); return c.json({ ok: true }); }); // ── CRUD: Photo Annotations ── routes.get("/api/annotations", async (c) => { if (!_syncServer) return c.json({ annotations: [] }); const { space } = await resolveOptionalRole(c); const doc = ensurePhotosDoc(space); return c.json({ annotations: Object.values(doc.annotations || {}) }); }); routes.post("/api/annotations", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space, claims } = auth; const { assetId, note } = await c.req.json(); if (!assetId || !note) return c.json({ error: "assetId and note required" }, 400); const id = crypto.randomUUID(); const docId = photosDocId(space); ensurePhotosDoc(space); _syncServer.changeDoc(docId, `annotate ${assetId}`, (d) => { d.annotations[id] = { assetId, note, authorDid: (claims.did as string) || claims.sub || '', createdAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.annotations[id], 201); }); routes.delete("/api/annotations/:annotationId", async (c) => { const auth = await requireRole(c, "member"); if (auth instanceof Response) return auth; if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { space } = auth; const annotationId = c.req.param("annotationId"); const docId = photosDocId(space); const doc = ensurePhotosDoc(space); if (!doc.annotations[annotationId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `delete annotation ${annotationId}`, (d) => { delete d.annotations[annotationId]; }); return c.json({ ok: true }); }); // ── Proxy: list space albums (space-scoped) ── routes.get("/api/albums", async (c) => { if (!_syncServer) return c.json({ albums: [] }); const { role, space } = await resolveOptionalRole(c); const doc = ensurePhotosDoc(space); if (!doc.enabled || !doc.spaceAlbumId) { return c.json({ albums: [] }); } const subAlbums = filterArrayByVisibility(Object.values(doc.subAlbums || {}), role); return c.json({ albums: subAlbums }); }); // ── Proxy: album detail (space-scoped) ── routes.get("/api/albums/:id", async (c) => { if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const { role, space } = await resolveOptionalRole(c); const albumId = c.req.param("id"); const doc = ensurePhotosDoc(space); // Verify album belongs to this space const subAlbum = doc.subAlbums[albumId]; if (!subAlbum) return c.json({ error: "Album not found" }, 404); try { const data = await immichGetAlbum(subAlbum.immichAlbumId); if (!data) return c.json({ error: "Album not found in Immich" }, 404); return c.json(data); } catch { return c.json({ error: "Failed to load album" }, 500); } }); // ── Proxy: recent assets (space-scoped) ── routes.get("/api/assets", async (c) => { if (!_syncServer) return c.json({ assets: [] }); const { space } = await resolveOptionalRole(c); const size = parseInt(c.req.query("size") || "50"); const doc = ensurePhotosDoc(space); if (!doc.enabled || !doc.spaceAlbumId) { return c.json({ assets: [] }); } try { const result = await immichSearchInAlbum(doc.spaceAlbumId, { size }); return c.json({ assets: result.items }); } catch { return c.json({ assets: [] }); } }); // ── Proxy: asset thumbnail (viewer+ auth) ── routes.get("/api/assets/:id/thumbnail", async (c) => { const id = c.req.param("id"); const size = c.req.query("size") || "thumbnail"; try { const result = await immichGetAssetThumbnail(id, size); if (!result) return c.body(null, 404); return c.body(result.body, 200, { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400", }); } catch { return c.body(null, 500); } }); // ── Proxy: full-size asset (viewer+ auth) ── routes.get("/api/assets/:id/original", async (c) => { const id = c.req.param("id"); try { const result = await immichGetAssetOriginal(id); if (!result) return c.body(null, 404); return c.body(result.body, 200, { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400", }); } catch { return c.body(null, 500); } }); // ── Embedded Immich UI (admin only) ── routes.get("/album", async (c) => { const auth = await requireRole(c, "admin"); if (auth instanceof Response) { // Fallback: render the page but the iframe will be empty for non-admins const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Photos | rSpace`, moduleId: "rphotos", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: `
Admin access required to use the Immich interface directly.
`, })); } const spaceSlug = auth.space; return c.html(renderExternalAppShell({ title: `${spaceSlug} — Immich | rSpace`, moduleId: "rphotos", spaceSlug, modules: getModuleInfoList(), appUrl: IMMICH_PUBLIC_URL, appName: "Immich", theme: "dark", })); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Photos | rSpace`, moduleId: "rphotos", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── MI Integration ── export function getSharedAlbumsForMI(space: string, limit = 5): { id: string; name: string; sharedAt: number }[] { if (!_syncServer) return []; const docId = photosDocId(space); const doc = _syncServer.getDoc(docId); if (!doc) return []; return Object.values(doc.sharedAlbums) .sort((a, b) => b.sharedAt - a.sharedAt) .slice(0, limit) .map((a) => ({ id: a.id, name: a.name, sharedAt: a.sharedAt })); } export const photosModule: RSpaceModule = { id: "rphotos", name: "rPhotos", icon: "📸", description: "Community photo commons", scoping: { defaultScope: 'global', userConfigurable: false }, docSchemas: [{ pattern: '{space}:photos:albums', description: 'Shared albums and annotations per space', init: photosSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rphotos.online", externalApp: { url: IMMICH_PUBLIC_URL, name: "Immich" }, async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "rphotos", name: "Recent Photos", kind: "data", description: "Stream of recently uploaded photos", emits: ["folk-image"], filterable: true, }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "albums", name: "Albums", icon: "📸", description: "Photo albums and collections" }, { path: "galleries", name: "Galleries", icon: "🖼️", description: "Public photo galleries" }, ], subPageInfos: [ { path: "album", title: "Photo Album", icon: "🖼️", tagline: "rPhotos Tool", description: "Browse a curated photo album with lightbox viewing, metadata display, and easy sharing. Powered by Immich.", features: [ { icon: "🔍", title: "Lightbox Viewer", text: "Full-screen photo viewing with EXIF data, zoom, and slideshow mode." }, { icon: "📤", title: "Easy Sharing", text: "Share individual photos or entire albums with a single link." }, { icon: "🏷️", title: "Smart Tags", text: "AI-powered face recognition and object tagging for easy discovery." }, ], }, ], };