import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; import { PluginRuntimeService } from './plugin-runtime.service'; import { stripEmoji } from './text-sanitize'; /** * Photo sources contributed by plugins that implement the `photoProvider` hook * (needs `hook:photo-provider`). This is the core consumer that makes the hook LIVE: * GET /api/plugin-photos/sources — the installed providers (id + name) * GET /api/plugin-photos/search?q=&page= — aggregate search across providers * GET /api/plugin-photos/item?pluginId=&id= — one photo by id from one provider * The picker fans these into its "plugin sources" tab. Additive + fail-safe: each * provider is called host→plugin on a short timeout, one that errors/times out is * skipped. Every field is NORMALIZED: strings length-capped, the photo count capped, * and thumbnail/full URLs must be http/https (an image src) — never javascript:/data:. */ interface DevPhoto { id: string; pluginId: string; title?: string; thumbnailUrl: string; fullUrl: string; takenAt?: string; } const MAX_PHOTOS = 60; // per provider per page const cap = (v: unknown, n: number): string => String(v ?? '').slice(0, n); /** http/https only — these become , so a javascript:/data: URL is XSS. */ function imageUrl(raw: unknown): string | undefined { if (typeof raw !== 'string' || raw === '' || raw.length > 4096) return undefined; try { const p = new URL(raw).protocol; return p === 'http:' || p === 'https:' ? raw : undefined; } catch { return undefined; } } function normalizePhotos(pluginId: string, raw: unknown): DevPhoto[] { const list = Array.isArray(raw) ? (raw as Array>) : []; const out: DevPhoto[] = []; for (const p of list) { if (out.length >= MAX_PHOTOS) break; if (!p || typeof p !== 'object') continue; const id = cap(p.id, 256); const thumb = imageUrl(p.thumbnailUrl); const full = imageUrl(p.fullUrl); if (!id || !thumb || !full) continue; // an unusable photo (no id / bad urls) is dropped out.push({ id, pluginId, // Strip emojis from the display title (but NOT the id — that round-trips to getById). title: p.title != null ? stripEmoji(cap(p.title, 200)) : undefined, thumbnailUrl: thumb, fullUrl: full, takenAt: typeof p.takenAt === 'string' ? cap(p.takenAt, 40) : undefined, }); } return out; } @Controller('api/plugin-photos') @UseGuards(JwtAuthGuard) export class PluginPhotosController { constructor(private readonly runtime: PluginRuntimeService) {} @Get('sources') sources(): { sources: Array<{ pluginId: string }> } { if (!pluginsEnabled()) return { sources: [] }; return { sources: this.runtime.providersOf('photoProvider').map((pluginId) => ({ pluginId })) }; } @Get('search') async search( @Query('q') q: string | undefined, @Query('page') pageRaw: string | undefined, @Req() req: Request & { user?: { id: number } }, ): Promise<{ providers: Array<{ pluginId: string; photos: DevPhoto[]; total: number; hasMore: boolean }> }> { if (!pluginsEnabled()) return { providers: [] }; const userId = req.user?.id; if (userId == null) return { providers: [] }; const query = cap(q, 200); const page = Math.max(1, Math.min(1000, Number(pageRaw) || 1)); const ids = this.runtime.providersOf('photoProvider'); const results = await Promise.all( ids.map(async (id) => { try { const raw = (await this.runtime.invokeHook(id, 'photoProvider', 'search', [query, { page, limit: MAX_PHOTOS }], userId, 5000)) as | { photos?: unknown; total?: unknown; hasMore?: unknown } | undefined; const photos = normalizePhotos(id, raw?.photos); if (photos.length === 0) return null; return { pluginId: id, photos, total: Number(raw?.total) || photos.length, hasMore: raw?.hasMore === true }; } catch { return null; } }), ); return { providers: results.filter((r): r is { pluginId: string; photos: DevPhoto[]; total: number; hasMore: boolean } => r !== null) }; } @Get('item') async item( @Query('pluginId') pluginId: string | undefined, @Query('id') id: string | undefined, @Req() req: Request & { user?: { id: number } }, ): Promise<{ photo: DevPhoto | null }> { if (!pluginsEnabled()) return { photo: null }; const userId = req.user?.id; if (userId == null || !pluginId || !id) return { photo: null }; if (!this.runtime.providersOf('photoProvider').includes(pluginId)) return { photo: null }; try { const raw = await this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [cap(id, 256)], userId, 5000); return { photo: normalizePhotos(pluginId, [raw])[0] ?? null }; } catch { return { photo: null }; } } }