diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index b0f7579c..12b4f4d8 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -92,6 +92,14 @@ module.exports = { args: 'run src/servers/sidecar/invoiceshelf/index.ts', watch: false, }, + // Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the + // owner from the UI and stored in `service_connections` — read here, never from the environment. + { + name: 'officer-memos', + script: 'bun', + args: 'run src/servers/sidecar/memos/index.ts', + watch: false, + }, // Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the // collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic // against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's; diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts index ce277760..f9e7a74a 100644 --- a/src/databases/officer_db/src/queries/service-connections.ts +++ b/src/databases/officer_db/src/queries/service-connections.ts @@ -13,7 +13,7 @@ import { encryptSecret, decryptSecret } from '../crypto'; // moment someone forgot to strip it. /** The services that keep a connection here. Extending it is a one-line change, not a migration. */ -export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer'; +export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist'; export type ServiceConnection = { id: number; diff --git a/src/servers/api/memos/router.ts b/src/servers/api/memos/router.ts new file mode 100644 index 00000000..c4d7726e --- /dev/null +++ b/src/servers/api/memos/router.ts @@ -0,0 +1,16 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// /api/memos/* — auth, then forward to officer-memos. No routes of its own and no Memos knowledge: +// this file must never grow app logic. +// +// The sidecar owns the Memos contract and holds the personal access token. The platform knows neither. + +const proxy = createSidecarProxy({ + name: 'memos', + prefix: '/api/memos', +}); + +export const memosRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getMemosServerUrl = proxy.getHttpUrl; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 3a6f3767..b1347930 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -31,6 +31,7 @@ import { walletRouter } from './api/wallet/router'; import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server'; +import { memosRouter } from './api/memos/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; import { notifyRouter } from './api/notify/router'; @@ -123,6 +124,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/terminal', terminalRouter); +protectedRouter.route('/memos', memosRouter); protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level) protectedRouter.route('/notify', notifyRouter); diff --git a/src/servers/sidecar/memos/index.ts b/src/servers/sidecar/memos/index.ts new file mode 100644 index 00000000..2f5f357d --- /dev/null +++ b/src/servers/sidecar/memos/index.ts @@ -0,0 +1,189 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { getServiceConnection, saveServiceConnection, deleteServiceConnection, recordServiceProbe } from 'officerdb'; +import { callMemos, getMemosConfig, invalidateMemosConfig, normalizeBase, probe } from './upstream'; + +// The officer-memos sidecar. Owns the whole Memos contract: the instance URL and the personal access +// token. The platform side is a thin auth-gated forwarder holding no Memos credentials. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/memos mount prefix before forwarding. +// +// GET /_health is the instance up, and does the stored token work +// GET /_config the connection MINUS the token +// POST /_config { url, token } — validated live before it is stored +// DELETE /_config forget the connection +// ANY /_api/* forwarded to the instance's own /api/*, token attached +// +// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint. Memos' REST +// API is generated from its protobufs and moves between minor versions; re-describing it here would +// mean a second thing to keep in sync, and the UI is going to speak the upstream's shapes anyway. +// The allow-list below is the one piece of policy: it keeps the sidecar from being a general-purpose +// SSRF hop into whatever else is on that host. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +// Everything under /api/v1 the UI legitimately needs. Auth routes are excluded on purpose: signin and +// signout would mint or destroy sessions on the instance, and this sidecar authenticates with a stored +// token rather than borrowing the owner's Memos session. +const ALLOWED = [/^\/api\/v1\/memos(\/|$|\?)/, /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/resources(\/|$|\?)/]; + +function getFreePort(): number { + const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probeServer.port; + probeServer.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +async function handleConfig(req: Request, userId: number): Promise { + if (req.method === 'GET') { + const connection = await getServiceConnection(userId, 'memos'); + return Response.json({ configured: !!connection, connection }); + } + + if (req.method === 'DELETE') { + const removed = await deleteServiceConnection(userId, 'memos'); + invalidateMemosConfig(userId); + return Response.json({ ok: removed }); + } + + if (req.method === 'POST') { + const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {}; + const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; + // A blank token on a save means "keep the stored one", so the URL can be corrected without the + // token being re-pasted. There is no way to read it back out to re-send it. + const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null; + + if (!url) return bad('url is required'); + if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://'); + + const existing = await getServiceConnection(userId, 'memos'); + if (!token && !existing?.hasSecret) return bad('an access token is required the first time'); + + // Probe with the credentials as they WILL be, so a save either stores something that works or + // fails with the reason. Storing first and discovering later makes every downstream screen fail + // mysteriously instead. + const current = token ? null : await getMemosConfig(userId); + const result = await probe({ id: existing?.id ?? 0, base: url, token: token ?? current?.token ?? null }); + if (!result.ok) return bad(result.error ?? 'could not reach the instance'); + + await saveServiceConnection({ + userId, + service: 'memos', + url, + secret: token ?? undefined, + version: result.version, + }); + invalidateMemosConfig(userId); + return Response.json({ ok: true, user: result.user }); + } + + return bad('method not allowed', 405); +} + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + // Memos resources are image/file attachments on a note. + maxRequestBodySize: 64 * 1024 * 1024, + async fetch(req) { + const url = new URL(req.url); + + const officerUser = req.headers.get('X-Officer-User'); + const userId = Number(officerUser); + if (!officerUser || !Number.isInteger(userId) || userId <= 0) { + return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); + } + + try { + if (url.pathname === '/_config') return await handleConfig(req, userId); + + const config = await getMemosConfig(userId); + + if (url.pathname === '/_health') { + if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); + const started = Date.now(); + const result = await probe(config); + const ms = Date.now() - started; + if (!result.ok) { + return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); + } + await recordServiceProbe(userId, 'memos', result.version); + return Response.json({ ok: true, configured: true, user: result.user, ms }); + } + + if (url.pathname.startsWith('/_api/')) { + if (!config) return Response.json({ error: 'memos not connected', configured: false }, { status: 503 }); + const subpath = url.pathname.slice('/_api'.length) + url.search; + if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403); + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const upstream = await callMemos(config, subpath, { + method: req.method, + headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {}, + body: hasBody ? await req.arrayBuffer() : undefined, + }); + return new Response(upstream.body, { + status: upstream.status, + headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, + }); + } + } catch (err) { + // Path only. A memo body is the owner's private note. + console.error(`[memos] ${req.method} ${url.pathname} failed`, String(err)); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + + return Response.json({ error: 'not found' }, { status: 404 }); + }, +}); + +console.log(`[memos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`); + +type ReplyFn = (msg: SidecarEvent) => void; + +function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { + switch (cmd.type) { + case 'ping': + reply({ type: 'pong', id: cmd.id }); + break; + default: + reply({ + type: 'error', + id: (cmd as SidecarCommand).id, + error: `Unknown command type: ${(cmd as Record).type}`, + }); + } +} + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'memos', + capabilities: ['memos'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, + onConnected() { + connection.send({ type: 'memos:server', port }); + console.log(`[memos] reported server port ${port} to API`); + }, +}); + +function shutdown(signal: string) { + console.log(`[memos] ${signal} received, shutting down...`); + try { + server.stop(true); + } catch { + /* already stopped */ + } + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/memos/upstream.ts b/src/servers/sidecar/memos/upstream.ts new file mode 100644 index 00000000..b67a61a4 --- /dev/null +++ b/src/servers/sidecar/memos/upstream.ts @@ -0,0 +1,70 @@ +import { getServiceCredentials } from 'officerdb'; + +// Where the Memos instance is and what it takes to talk to it. The credential lives here and only here — +// the platform forwards to this sidecar and holds nothing. +// +// Memos authenticates with a personal access token (`Authorization: Bearer …`), minted from Memos' own +// Settings → Access Tokens. That is the OWNER'S to create and paste in; there is no way to derive one +// from a platform session, and no reason to want one. + +export type MemosConfig = { id: number; base: string; token: string | null }; + +/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +const cache = new Map(); + +export function invalidateMemosConfig(userId: number): void { + cache.delete(userId); +} + +export async function getMemosConfig(userId: number): Promise { + if (cache.has(userId)) return cache.get(userId) ?? null; + const row = await getServiceCredentials(userId, 'memos'); + const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null; + cache.set(userId, config); + return config; +} + +export const authHeaders = (config: MemosConfig): Record => + config.token ? { Authorization: `Bearer ${config.token}` } : {}; + +/** + * One call against the instance. Returns the raw Response so callers can stream or inspect status — + * nothing here parses a memo, because a memo is the owner's note and this layer has no business + * reading it. + */ +export async function callMemos(config: MemosConfig, path: string, init?: RequestInit): Promise { + const headers = { ...authHeaders(config), ...((init?.headers as Record) ?? {}) }; + return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) }); +} + +export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string }; + +/** + * Is the instance up, and does the stored token actually work? + * + * Two checks, because they fail differently and the UI needs to tell them apart: `/healthz` answers + * unauthenticated so a bad URL is distinguishable from a bad token, and `/api/v1/users/me` is the + * cheapest endpoint that genuinely requires auth. Memos returns 200 with an empty list for + * unauthenticated reads rather than 401, so "the list came back" proves nothing on its own. + */ +export async function probe(config: MemosConfig): Promise { + try { + const health = await fetch(`${config.base}/healthz`, { signal: AbortSignal.timeout(8000) }); + if (!health.ok) return { ok: false, version: null, user: null, error: `instance returned ${health.status}` }; + } catch (err) { + return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` }; + } + + if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' }; + + try { + const me = await callMemos(config, '/api/v1/users/me'); + if (!me.ok) return { ok: false, version: null, user: null, error: `token rejected (${me.status})` }; + const body = (await me.json().catch(() => ({}))) as { name?: string; username?: string; nickname?: string }; + return { ok: true, version: null, user: body.nickname || body.username || body.name || null }; + } catch (err) { + return { ok: false, version: null, user: null, error: String(err) }; + } +} diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 271e1bdb..6412b626 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -73,6 +73,8 @@ export type SidecarEvent = | { type: 'invoiceshelf:server'; port: number } // Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect | { type: 'photos:server'; port: number } + // Memos — the sidecar reports where its HTTP server is listening (random port) on connect + | { type: 'memos:server'; port: number } // CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect. // One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI). | { type: 'caldav:server'; port: number }