/** * Photos module — community photo commons powered by Immich. * * Provides a gallery UI within the rSpace shell that connects to * the Immich instance at {space}.rphotos.online. Proxies API requests * for albums and thumbnails to avoid CORS issues. */ 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 { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { photosSchema, photosDocId } from './schemas'; import type { PhotosDoc, SharedAlbum, PhotoAnnotation } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); const IMMICH_BASE = process.env.RPHOTOS_IMMICH_URL || "http://localhost:2284"; // ── 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; } // ── CRUD: Curated Albums ── routes.get("/api/curations", (c) => { if (!_syncServer) return c.json({ albums: [] }); const space = c.req.param("space") || "demo"; const doc = ensurePhotosDoc(space); return c.json({ albums: Object.values(doc.sharedAlbums || {}) }); }); routes.post("/api/curations", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; 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 token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; 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", (c) => { if (!_syncServer) return c.json({ annotations: [] }); const space = c.req.param("space") || "demo"; const doc = ensurePhotosDoc(space); return c.json({ annotations: Object.values(doc.annotations || {}) }); }); routes.post("/api/annotations", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; 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 token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; 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 }); }); const IMMICH_API_KEY = process.env.RPHOTOS_API_KEY || ""; const IMMICH_PUBLIC_URL = process.env.RPHOTOS_IMMICH_PUBLIC_URL || "https://demo.rphotos.online"; // ── Proxy: list shared albums ── routes.get("/api/albums", async (c) => { try { const res = await fetch(`${IMMICH_BASE}/api/albums?shared=true`, { headers: { "x-api-key": IMMICH_API_KEY }, }); if (!res.ok) return c.json({ albums: [] }); const albums = await res.json(); return c.json({ albums }); } catch { return c.json({ albums: [] }); } }); // ── Proxy: album detail with assets ── routes.get("/api/albums/:id", async (c) => { const id = c.req.param("id"); try { const res = await fetch(`${IMMICH_BASE}/api/albums/${id}`, { headers: { "x-api-key": IMMICH_API_KEY }, }); if (!res.ok) return c.json({ error: "Album not found" }, 404); return c.json(await res.json()); } catch { return c.json({ error: "Failed to load album" }, 500); } }); // ── Proxy: recent assets ── routes.get("/api/assets", async (c) => { const size = c.req.query("size") || "50"; try { const res = await fetch(`${IMMICH_BASE}/api/search/metadata`, { method: "POST", headers: { "x-api-key": IMMICH_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ size: parseInt(size), order: "desc", type: "IMAGE", }), }); if (!res.ok) return c.json({ assets: [] }); const data = await res.json(); return c.json({ assets: data.assets?.items || [] }); } catch { return c.json({ assets: [] }); } }); // ── Proxy: asset thumbnail ── routes.get("/api/assets/:id/thumbnail", async (c) => { const id = c.req.param("id"); const size = c.req.query("size") || "thumbnail"; try { const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/thumbnail?size=${size}`, { headers: { "x-api-key": IMMICH_API_KEY }, }); if (!res.ok) return c.body(null, 404); const body = await res.arrayBuffer(); return c.body(body, 200, { "Content-Type": res.headers.get("Content-Type") || "image/jpeg", "Cache-Control": "public, max-age=86400", }); } catch { return c.body(null, 500); } }); // ── Proxy: full-size asset ── routes.get("/api/assets/:id/original", async (c) => { const id = c.req.param("id"); try { const res = await fetch(`${IMMICH_BASE}/api/assets/${id}/original`, { headers: { "x-api-key": IMMICH_API_KEY }, }); if (!res.ok) return c.body(null, 404); const body = await res.arrayBuffer(); return c.body(body, 200, { "Content-Type": res.headers.get("Content-Type") || "image/jpeg", "Cache-Control": "public, max-age=86400", }); } catch { return c.body(null, 500); } }); // ── Embedded Immich UI ── routes.get("/album", (c) => { const spaceSlug = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || spaceSlug; 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"; const dataSpace = c.get("effectiveSpace") || spaceSlug; 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." }, ], }, ], };