1
20import { Controller, Get, Inject, Param, Query, Req } from "@nestjs/common";
21import type { Request } from "express";
22import { assertPublicUrl, SsrfError } from "@factory/shared";
23import { PrismaService } from "../../common/prisma.service.js";
24import { ApiError } from "../../common/errors.js";
25import { requireUser } from "../../common/types.js";
26import { assertCan } from "../../lib/authorize.js";
27import { workspaceOrgPolicy } from "./org-policy.service.js";
28
29const MAX_HOPS = 5;
30const MAX_BYTES = 512 * 1024;
31
32@Controller("workspaces/:workspaceId/embeds/preview")
33export class EmbedPreviewController {
34
35
36 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
37
38 @Get()
39 async preview(
40 @Param("workspaceId") workspaceId: string,
41 @Query("url") url: string | undefined,
42 @Req() req: Request
43 ) {
44 const userId = requireUser(req);
45 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
46
47 const target = new URL(url ?? "");
48 if (target.protocol !== "http:" && target.protocol !== "https:") {
49 throw ApiError.validation("Only http(s) URLs can be previewed.", [{ reason: "unsupported_protocol" }]);
50 }
51
52
53 const { policy } = await workspaceOrgPolicy(this.prisma, workspaceId);
54 if (policy?.allowExternalEmbeds === false) {
55 throw ApiError.forbidden("External embeds are disabled by your organization.", [
56 { reason: "external_embeds_disabled" }
57 ]);
58 }
59
60
61
62 let current = target;
63 let last: Response | null = null;
64 for (let hop = 0; hop <= MAX_HOPS; hop++) {
65 try {
66 await assertPublicUrl(current.toString());
67 } catch (err) {
68 if (err instanceof SsrfError) {
69 throw ApiError.validation("URL is not allowed for preview.", [{ reason: "ssrf_blocked" }]);
70 }
71 throw err;
72 }
73 last = await fetch(current, {
74 redirect: "manual",
75 signal: AbortSignal.timeout(8000),
76 headers: { "user-agent": "FactoryEmbedPreview/1.0" }
77 });
78 if (last.status >= 300 && last.status < 400) {
79 const loc = last.headers.get("location");
80 if (!loc) break;
81 current = new URL(loc, current);
82 continue;
83 }
84 break;
85 }
86 const res = last;
87 if (!res || res.status < 200 || res.status >= 300) {
88 throw ApiError.validation(`Preview fetch failed (upstream ${res?.status ?? "n/a"}).`, [
89 { reason: "preview_fetch_failed" }
90 ]);
91 }
92 const status = res.status;
93 const contentType = res.headers.get("content-type") ?? "";
94 const body = (await readBounded(res, MAX_BYTES)).toString("utf8");
95
96 return {
97 ok: true,
98 url: current.toString(),
99 status,
100 contentType,
101 title: extractTitle(body) ?? undefined,
102 snippet: extractSnippet(body)
103 };
104 }
105}
106
107
108
109
110async function readBounded(res: Response, max: number): Promise<Buffer> {
111 if (!res.body) return Buffer.from(await res.arrayBuffer()).subarray(0, max);
112 const chunks: Buffer[] = [];
113 let total = 0;
114 const reader = res.body.getReader();
115 for (;;) {
116 const { done, value } = await reader.read();
117 if (done) break;
118 const b = Buffer.from(value);
119 chunks.push(b);
120 total += b.length;
121 if (total >= max) {
122 await reader.cancel().catch(() => undefined);
123 break;
124 }
125 }
126 return Buffer.concat(chunks).subarray(0, max);
127}
128
129
130export function extractTitle(html: string): string | null {
131 const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) ?? /property=["']og:title["'][^>]*content=["']([^"']+)["']/i.exec(html);
132 if (!m) return null;
133 return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 300) || null;
134}
135
136
137export function extractSnippet(html: string): string {
138 return decodeEntities(
139 html
140 .replace(/<script[\s\S]*?<\/script>/gi, " ")
141 .replace(/<style[\s\S]*?<\/style>/gi, " ")
142 .replace(/<!--[\s\S]*?-->/g, " ")
143 .replace(/<[^>]+>/g, " ")
144 )
145 .replace(/\s+/g, " ")
146 .trim()
147 .slice(0, 500);
148}
149
150function decodeEntities(s: string): string {
151 return s
152 .replace(/</g, "<")
153 .replace(/>/g, ">")
154 .replace(/"/g, '"')
155 .replace(/'|'/g, "'")
156 .replace(/ /g, " ")
157 .replace(/&/g, "&");
158}
159