/** * TASK-0133 — the SERVER-AUTHORITATIVE external-embed preview endpoint * (the `allowExternalEmbeds` enforcement point #5). * * `GET /workspaces/:workspaceId/embeds/preview?url=` — * the editor's video/embed/link-preview blocks fetch their content through * this endpoint (NEVER the browser directly): the server (1) resolves the * workspace's org policy, (2) when `allowExternalEmbeds: false` → `403 * {reason:"external_embeds_disabled"}` (the client-side placeholder is UX * only — the server is authoritative, AGENTS rule 19), (3) otherwise fetches * with an SSRF guard (`assertPublicUrl` — DNS-resolved, private-IP rejected, * every REDIRECT re-checked — a redirect to an internal host is blocked). * * Returns a bounded preview: `status`, `contentType`, `title` (the * ``/`og:title`), `snippet` (tag-stripped text, 500 chars). The * full document is never proxied (this is a PREVIEW, not a caching proxy — * the embed iframe still loads in the browser; this endpoint is the * policy gate + the card's metadata source). */ import { Controller, Get, Inject, Param, Query, Req } from "@nestjs/common"; import type { Request } from "express"; import { assertPublicUrl, SsrfError } from "@factory/shared"; import { PrismaService } from "../../common/prisma.service.js"; import { ApiError } from "../../common/errors.js"; import { requireUser } from "../../common/types.js"; import { assertCan } from "../../lib/authorize.js"; import { workspaceOrgPolicy } from "./org-policy.service.js"; const MAX_HOPS = 5; const MAX_BYTES = 512 * 1024; // the preview only needs the head @Controller("workspaces/:workspaceId/embeds/preview") export class EmbedPreviewController { // Explicit @Inject (see OrgAdminController — bare params break under // vitest/esbuild's missing design:paramtypes). constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} @Get() async preview( @Param("workspaceId") workspaceId: string, @Query("url") url: string | undefined, @Req() req: Request ) { const userId = requireUser(req); await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const target = new URL(url ?? ""); if (target.protocol !== "http:" && target.protocol !== "https:") { throw ApiError.validation("Only http(s) URLs can be previewed.", [{ reason: "unsupported_protocol" }]); } // The authoritative policy check (org policy → 403 when disabled). const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId); if (policy?.allowExternalEmbeds === false) { throw ApiError.forbidden("External embeds are disabled by your organization.", [ { reason: "external_embeds_disabled" } ]); } // SSRF-guarded fetch with every hop re-checked (a redirect to // 169.254.169.254 / 10.x must NOT be followed). let current = target; let last: Response | null = null; for (let hop = 0; hop <= MAX_HOPS; hop++) { try { await assertPublicUrl(current.toString()); } catch (err) { if (err instanceof SsrfError) { throw ApiError.validation("URL is not allowed for preview.", [{ reason: "ssrf_blocked" }]); } throw err; } last = await fetch(current, { redirect: "manual", signal: AbortSignal.timeout(8000), headers: { "user-agent": "FactoryEmbedPreview/1.0" } }); if (last.status >= 300 && last.status < 400) { const loc = last.headers.get("location"); if (!loc) break; current = new URL(loc, current); continue; } break; } const res = last; if (!res || res.status < 200 || res.status >= 300) { throw ApiError.validation(`Preview fetch failed (upstream ${res?.status ?? "n/a"}).`, [ { reason: "preview_fetch_failed" } ]); } const status = res.status; const contentType = res.headers.get("content-type") ?? ""; const body = (await readBounded(res, MAX_BYTES)).toString("utf8"); return { ok: true, url: current.toString(), status, contentType, title: extractTitle(body) ?? undefined, snippet: extractSnippet(body) }; } } // Keep the import referenced (the guard is async; the error type is exported // for the controller's catch shape in tests). async function readBounded(res: Response, max: number): Promise<Buffer> { if (!res.body) return Buffer.from(await res.arrayBuffer()).subarray(0, max); const chunks: Buffer[] = []; let total = 0; const reader = res.body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; const b = Buffer.from(value); chunks.push(b); total += b.length; if (total >= max) { await reader.cancel().catch(() => undefined); break; } } return Buffer.concat(chunks).subarray(0, max); } /** `<title>…` (first one, entities unescaped, 300 chars). */ export function extractTitle(html: string): string | null { const m = /]*>([\s\S]*?)<\/title>/i.exec(html) ?? /property=["']og:title["'][^>]*content=["']([^"']+)["']/i.exec(html); if (!m) return null; return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 300) || null; } /** Tag-stripped + whitespace-collapsed text, first 500 chars. */ export function extractSnippet(html: string): string { return decodeEntities( html .replace(//gi, " ") .replace(//gi, " ") .replace(//g, " ") .replace(/<[^>]+>/g, " ") ) .replace(/\s+/g, " ") .trim() .slice(0, 500); } function decodeEntities(s: string): string { return s .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/ /g, " ") .replace(/&/g, "&"); }