318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* 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 { resolveCallerRole } 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, 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<PhotosDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<PhotosDoc>(), '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", async (c) => {
|
|
if (!_syncServer) return c.json({ albums: [] });
|
|
const space = c.req.param("space") || "demo";
|
|
|
|
// Resolve caller role for membrane filtering
|
|
let callerRole: 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) callerRole = resolved.role;
|
|
} catch {}
|
|
}
|
|
|
|
const doc = ensurePhotosDoc(space);
|
|
return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), callerRole) });
|
|
});
|
|
|
|
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<PhotosDoc>(docId, `share album ${id}`, (d) => {
|
|
d.sharedAlbums[id] = { id, name, description, sharedBy: claims.sub || null, sharedAt: Date.now() };
|
|
});
|
|
const updated = _syncServer.getDoc<PhotosDoc>(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<PhotosDoc>(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<PhotosDoc>(docId, `annotate ${assetId}`, (d) => {
|
|
d.annotations[id] = { assetId, note, authorDid: (claims.did as string) || claims.sub || '', createdAt: Date.now() };
|
|
});
|
|
const updated = _syncServer.getDoc<PhotosDoc>(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<PhotosDoc>(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: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
|
|
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js?v=2"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
|
|
}));
|
|
});
|
|
|
|
// ── 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<PhotosDoc>(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." },
|
|
],
|
|
},
|
|
],
|
|
};
|