@syengup/friday-channel-next 0.1.27 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * SSRF guard + restricted fetch for the link-preview endpoint.
3
+ *
4
+ * Unlike `downloadRemoteMedia` (agent-supplied URLs for outbound media), link-preview fetches
5
+ * URLs that originate from arbitrary message text, so every hop must be validated: protocol,
6
+ * port, hostname literals, and the full set of DNS-resolved addresses. Redirects are followed
7
+ * manually so each target is re-checked before the next request.
8
+ *
9
+ * Known residual risk: DNS rebinding TOCTOU — we validate resolved addresses, then `fetch`
10
+ * resolves again. Closing that gap requires dialing by IP with SNI/Host rewriting, which is
11
+ * disproportionate for preview metadata; accepted at this threat level.
12
+ */
13
+ import dns from "node:dns/promises";
14
+ import net from "node:net";
15
+ const MAX_REDIRECTS = 5;
16
+ export class BlockedUrlError extends Error {
17
+ reason;
18
+ constructor(reason) {
19
+ super(`Blocked URL: ${reason}`);
20
+ this.name = "BlockedUrlError";
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ /** Parse an absolute http/https URL. Returns null for anything else (caller maps to invalid_url). */
25
+ export function parseHttpUrl(raw) {
26
+ let url;
27
+ try {
28
+ url = new URL(raw.trim());
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:")
34
+ return null;
35
+ return url;
36
+ }
37
+ const BLOCKED_HOST_SUFFIXES = [".local", ".internal", ".home.arpa", ".localhost"];
38
+ /** Synchronous literal checks: port, hostname blocklist, IP-literal hosts. Throws BlockedUrlError. */
39
+ export function assertPublicHttpUrl(url) {
40
+ if (url.port && url.port !== "80" && url.port !== "443") {
41
+ throw new BlockedUrlError(`port ${url.port} not allowed`);
42
+ }
43
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
44
+ if (!host)
45
+ throw new BlockedUrlError("empty host");
46
+ if (host === "localhost" || BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s))) {
47
+ throw new BlockedUrlError(`host "${host}" is not public`);
48
+ }
49
+ // IPv6 literal in a URL comes bracketed: strip for net.isIP.
50
+ const bareHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
51
+ if (net.isIP(bareHost) && isPrivateAddress(bareHost)) {
52
+ throw new BlockedUrlError(`address ${bareHost} is private/reserved`);
53
+ }
54
+ }
55
+ /** True when the IP (v4 or v6, including ::ffff: mapped v4) is private, loopback, or reserved. */
56
+ export function isPrivateAddress(ip) {
57
+ const version = net.isIP(ip);
58
+ if (version === 4)
59
+ return isPrivateIPv4(ip);
60
+ if (version === 6)
61
+ return isPrivateIPv6(ip);
62
+ return true; // not an IP at all — treat as unsafe
63
+ }
64
+ function isPrivateIPv4(ip) {
65
+ const parts = ip.split(".").map(Number);
66
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
67
+ return true;
68
+ const [a, b] = parts;
69
+ if (a === 0)
70
+ return true; // 0.0.0.0/8
71
+ if (a === 10)
72
+ return true; // 10/8
73
+ if (a === 100 && b >= 64 && b <= 127)
74
+ return true; // 100.64/10 CGNAT
75
+ if (a === 127)
76
+ return true; // loopback
77
+ if (a === 169 && b === 254)
78
+ return true; // link-local
79
+ if (a === 172 && b >= 16 && b <= 31)
80
+ return true; // 172.16/12
81
+ if (a === 192 && b === 0 && parts[2] === 0)
82
+ return true; // 192.0.0/24
83
+ if (a === 192 && b === 168)
84
+ return true; // 192.168/16
85
+ // 198.18/15 (benchmarking) intentionally NOT blocked: fake-IP DNS setups (clash/surge tun
86
+ // mode, common on gateway hosts) resolve EVERY domain into this range, so blocking it kills
87
+ // all lookups. The range is not used for real LAN services, so the SSRF exposure is nil.
88
+ if (a >= 224)
89
+ return true; // multicast 224/4 + reserved 240/4 + broadcast
90
+ return false;
91
+ }
92
+ function isPrivateIPv6(ip) {
93
+ const lower = ip.toLowerCase();
94
+ // ::ffff:a.b.c.d mapped IPv4 → validate the embedded v4.
95
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
96
+ if (mapped)
97
+ return isPrivateIPv4(mapped[1]);
98
+ if (lower === "::" || lower === "::1")
99
+ return true; // unspecified / loopback
100
+ const head = lower.split(":")[0];
101
+ if (head.startsWith("fc") || head.startsWith("fd"))
102
+ return true; // fc00::/7 ULA
103
+ if (/^fe[89ab]/.test(head))
104
+ return true; // fe80::/10 link-local
105
+ return false;
106
+ }
107
+ /** Resolve the hostname and require every returned address to be public. Throws BlockedUrlError. */
108
+ export async function assertResolvesPublic(url) {
109
+ const host = url.hostname.replace(/^\[|\]$/g, "");
110
+ if (net.isIP(host)) {
111
+ if (isPrivateAddress(host))
112
+ throw new BlockedUrlError(`address ${host} is private/reserved`);
113
+ return;
114
+ }
115
+ let addresses;
116
+ try {
117
+ addresses = await dns.lookup(host, { all: true, verbatim: true });
118
+ }
119
+ catch {
120
+ throw new Error(`DNS lookup failed for ${host}`);
121
+ }
122
+ if (!addresses.length)
123
+ throw new Error(`DNS lookup returned no addresses for ${host}`);
124
+ for (const { address } of addresses) {
125
+ if (isPrivateAddress(address)) {
126
+ throw new BlockedUrlError(`${host} resolves to private/reserved address ${address}`);
127
+ }
128
+ }
129
+ }
130
+ const PREVIEW_USER_AGENT = "Mozilla/5.0 (compatible; OpenClawLinkPreview/1.0)";
131
+ /**
132
+ * Fetch a public http/https URL with manual redirects (≤5 hops, each hop re-validated) and a
133
+ * streamed size cap (Content-Length is not trusted). Returns null on ordinary failures (non-2xx,
134
+ * oversize, timeout, bad content type, DNS error); throws BlockedUrlError on SSRF rejection.
135
+ */
136
+ export async function fetchPublicUrl(rawUrl, opts) {
137
+ const controller = new AbortController();
138
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
139
+ try {
140
+ let current = rawUrl;
141
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
142
+ const url = parseHttpUrl(current);
143
+ if (!url)
144
+ return null;
145
+ assertPublicHttpUrl(url);
146
+ await assertResolvesPublic(url);
147
+ const res = await fetch(url, {
148
+ redirect: "manual",
149
+ signal: controller.signal,
150
+ headers: {
151
+ "User-Agent": PREVIEW_USER_AGENT,
152
+ ...(opts.accept ? { Accept: opts.accept } : {}),
153
+ },
154
+ });
155
+ if (res.status >= 300 && res.status < 400) {
156
+ const location = res.headers.get("location");
157
+ await res.body?.cancel().catch(() => { });
158
+ if (!location)
159
+ return null;
160
+ try {
161
+ current = new URL(location, url).toString();
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ continue;
167
+ }
168
+ if (!res.ok) {
169
+ await res.body?.cancel().catch(() => { });
170
+ return null;
171
+ }
172
+ const contentType = res.headers.get("content-type")?.trim().toLowerCase() ?? "";
173
+ if (opts.requireContentTypePrefixes &&
174
+ !opts.requireContentTypePrefixes.some((p) => contentType.startsWith(p))) {
175
+ await res.body?.cancel().catch(() => { });
176
+ return null;
177
+ }
178
+ const body = await readBodyCapped(res, opts.maxBytes);
179
+ if (body === null)
180
+ return null;
181
+ return { finalUrl: url.toString(), contentType, body };
182
+ }
183
+ return null; // too many redirects
184
+ }
185
+ catch (err) {
186
+ if (err instanceof BlockedUrlError)
187
+ throw err;
188
+ return null; // timeout / network / DNS failure
189
+ }
190
+ finally {
191
+ clearTimeout(timer);
192
+ }
193
+ }
194
+ /** Stream the response body, aborting once it exceeds maxBytes. */
195
+ async function readBodyCapped(res, maxBytes) {
196
+ if (!res.body) {
197
+ const buffer = Buffer.from(await res.arrayBuffer());
198
+ return buffer.length <= maxBytes ? buffer : null;
199
+ }
200
+ const reader = res.body.getReader();
201
+ const chunks = [];
202
+ let total = 0;
203
+ try {
204
+ for (;;) {
205
+ const { done, value } = await reader.read();
206
+ if (done)
207
+ break;
208
+ if (value) {
209
+ total += value.byteLength;
210
+ if (total > maxBytes) {
211
+ await reader.cancel().catch(() => { });
212
+ return null;
213
+ }
214
+ chunks.push(Buffer.from(value));
215
+ }
216
+ }
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ const buffer = Buffer.concat(chunks);
222
+ return buffer.length ? buffer : null;
223
+ }
@@ -10,7 +10,7 @@
10
10
  import { readFileSync } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
12
  /** Keep in sync with package.json "version" as a last-resort fallback. */
13
- const FALLBACK_VERSION = "0.1.27";
13
+ const FALLBACK_VERSION = "0.1.28";
14
14
  function resolvePluginVersion() {
15
15
  // dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
16
16
  // source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -328,6 +328,7 @@ export function guessMimeType(filename: string): string {
328
328
  ".jpeg": "image/jpeg",
329
329
  ".gif": "image/gif",
330
330
  ".webp": "image/webp",
331
+ ".ico": "image/x-icon",
331
332
  ".heic": "image/heic",
332
333
  ".pdf": "application/pdf",
333
334
  ".mp4": "video/mp4",
@@ -0,0 +1,242 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import type { IncomingMessage, ServerResponse } from "node:http";
7
+
8
+ vi.mock("node:dns/promises", () => ({
9
+ default: { lookup: vi.fn() },
10
+ }));
11
+
12
+ import dns from "node:dns/promises";
13
+ import { handleLinkPreview } from "./link-preview.js";
14
+ import { resetLinkPreviewCacheForTest, type LinkPreviewPayload } from "../../link-preview/preview-service.js";
15
+ import { setAttachmentsDirForTest } from "./files.js";
16
+ import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
17
+
18
+ const lookupMock = vi.mocked(dns.lookup);
19
+
20
+ class MockRes extends EventEmitter {
21
+ statusCode = 0;
22
+ headers: Record<string, string> = {};
23
+ body = "";
24
+ setHeader(name: string, value: string): void {
25
+ this.headers[name.toLowerCase()] = value;
26
+ }
27
+ end(body?: string): void {
28
+ if (body) this.body += body;
29
+ this.emit("finish");
30
+ }
31
+ }
32
+
33
+ function makeReq(query: string | null, token: string | null = "tok"): IncomingMessage {
34
+ return {
35
+ method: "GET",
36
+ url: query == null ? "/friday-next/link-preview" : `/friday-next/link-preview?url=${encodeURIComponent(query)}`,
37
+ headers: token ? { authorization: `Bearer ${token}` } : {},
38
+ } as unknown as IncomingMessage;
39
+ }
40
+
41
+ async function invoke(req: IncomingMessage): Promise<MockRes> {
42
+ const res = new MockRes();
43
+ await handleLinkPreview(req, res as unknown as ServerResponse);
44
+ return res;
45
+ }
46
+
47
+ const PAGE_HTML = `<html><head>
48
+ <meta property="og:title" content="Hello Page">
49
+ <meta property="og:description" content="A description">
50
+ <meta property="og:site_name" content="Example Site">
51
+ <meta property="og:image" content="https://example.com/cover.png">
52
+ </head></html>`;
53
+
54
+ const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0]);
55
+
56
+ let tmpDir: string;
57
+
58
+ beforeEach(() => {
59
+ resetLinkPreviewCacheForTest();
60
+ lookupMock.mockReset();
61
+ lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }] as never);
62
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "link-preview-test-"));
63
+ setAttachmentsDirForTest(tmpDir);
64
+ setFridayNextRuntime({
65
+ config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
66
+ } as never);
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.unstubAllGlobals();
71
+ setAttachmentsDirForTest(null);
72
+ clearFridayNextRuntime();
73
+ fs.rmSync(tmpDir, { recursive: true, force: true });
74
+ });
75
+
76
+ describe("handleLinkPreview", () => {
77
+ it("405 on non-GET", async () => {
78
+ const res = new MockRes();
79
+ await handleLinkPreview({ method: "POST", url: "/friday-next/link-preview", headers: {} } as never, res as never);
80
+ expect(res.statusCode).toBe(405);
81
+ });
82
+
83
+ it("401 without bearer token", async () => {
84
+ const res = await invoke(makeReq("https://example.com/", null));
85
+ expect(res.statusCode).toBe(401);
86
+ });
87
+
88
+ it("400 when url param is missing or not http(s)", async () => {
89
+ expect((await invoke(makeReq(null))).statusCode).toBe(400);
90
+ expect((await invoke(makeReq("ftp://example.com/x"))).statusCode).toBe(400);
91
+ expect((await invoke(makeReq("not a url"))).statusCode).toBe(400);
92
+ });
93
+
94
+ it("403 blocked_url for private targets", async () => {
95
+ const res = await invoke(makeReq("http://192.168.1.1/admin"));
96
+ expect(res.statusCode).toBe(403);
97
+ expect(JSON.parse(res.body).error).toBe("blocked_url");
98
+ });
99
+
100
+ it("200 with full preview payload and re-hosted cover image", async () => {
101
+ vi.stubGlobal(
102
+ "fetch",
103
+ vi.fn(async (input: URL | string) => {
104
+ const url = String(input);
105
+ if (url.includes("cover.png")) {
106
+ return new Response(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } });
107
+ }
108
+ return new Response(PAGE_HTML, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } });
109
+ }),
110
+ );
111
+ const res = await invoke(makeReq("https://example.com/article"));
112
+ expect(res.statusCode).toBe(200);
113
+ const body = JSON.parse(res.body) as { ok: boolean; preview: LinkPreviewPayload };
114
+ expect(body.ok).toBe(true);
115
+ expect(body.preview.title).toBe("Hello Page");
116
+ expect(body.preview.description).toBe("A description");
117
+ expect(body.preview.siteName).toBe("Example Site");
118
+ expect(body.preview.url).toBe("https://example.com/article");
119
+ expect(body.preview.finalUrl).toBe("https://example.com/article");
120
+ expect(body.preview.imageUrl).toMatch(/^\/friday-next\/files\/.+\.png$/);
121
+ expect(typeof body.preview.fetchedAt).toBe("number");
122
+ // 封面图确实落盘
123
+ const token = decodeURIComponent(body.preview.imageUrl!.split("/").pop()!);
124
+ expect(fs.existsSync(path.join(tmpDir, token))).toBe(true);
125
+ });
126
+
127
+ it("200 with imageUrl null when the cover download fails", async () => {
128
+ vi.stubGlobal(
129
+ "fetch",
130
+ vi.fn(async (input: URL | string) => {
131
+ const url = String(input);
132
+ if (url.includes("cover.png")) return new Response("nope", { status: 404 });
133
+ return new Response(PAGE_HTML, { status: 200, headers: { "content-type": "text/html" } });
134
+ }),
135
+ );
136
+ const res = await invoke(makeReq("https://example.com/article"));
137
+ expect(res.statusCode).toBe(200);
138
+ expect(JSON.parse(res.body).preview.imageUrl).toBeNull();
139
+ });
140
+
141
+ it("falls back siteName to hostname when og:site_name is absent", async () => {
142
+ const html = `<meta property="og:title" content="T">`;
143
+ vi.stubGlobal(
144
+ "fetch",
145
+ vi.fn(async () => new Response(html, { status: 200, headers: { "content-type": "text/html" } })),
146
+ );
147
+ const res = await invoke(makeReq("https://example.com/x"));
148
+ expect(JSON.parse(res.body).preview.siteName).toBe("example.com");
149
+ });
150
+
151
+ it("200 minimal hostname card when a reachable page has no OG metadata", async () => {
152
+ // 可达但无 OG/title → 退到 hostname 卡片(不再折叠)。
153
+ vi.stubGlobal(
154
+ "fetch",
155
+ vi.fn(async () => new Response("<html><body>plain</body></html>", { status: 200, headers: { "content-type": "text/html" } })),
156
+ );
157
+ const res = await invoke(makeReq("https://example.com/bare"));
158
+ expect(res.statusCode).toBe(200);
159
+ expect(JSON.parse(res.body).preview.title).toBe("example.com");
160
+ });
161
+
162
+ it("ICO favicon re-hosted into iconUrl", async () => {
163
+ const ICO_BYTES = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]);
164
+ vi.stubGlobal(
165
+ "fetch",
166
+ vi.fn(async (input: URL | string) => {
167
+ const url = String(input);
168
+ if (url.includes("favicon")) {
169
+ return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/x-icon" } });
170
+ }
171
+ return new Response(`<meta property="og:title" content="Titled">`, { status: 200, headers: { "content-type": "text/html" } });
172
+ }),
173
+ );
174
+ const res = await invoke(makeReq("https://example.com/p"));
175
+ const preview = JSON.parse(res.body).preview as LinkPreviewPayload;
176
+ expect(preview.iconUrl).toMatch(/^\/friday-next\/files\/.+\.ico$/);
177
+ const token = decodeURIComponent(preview.iconUrl!.split("/").pop()!);
178
+ expect(fs.existsSync(path.join(tmpDir, token))).toBe(true);
179
+ });
180
+
181
+ it("200 minimal card for a bot-blocked page whose favicon.ico is reachable (zhihu-style)", async () => {
182
+ const ICO_BYTES = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]);
183
+ vi.stubGlobal(
184
+ "fetch",
185
+ vi.fn(async (input: URL | string) => {
186
+ const url = String(input);
187
+ if (url.endsWith("/favicon.ico")) {
188
+ return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/vnd.microsoft.icon" } });
189
+ }
190
+ return new Response("blocked", { status: 403, headers: { "content-type": "text/html" } }); // page blocks bots
191
+ }),
192
+ );
193
+ const res = await invoke(makeReq("https://www.zhihu.com/question/123"));
194
+ expect(res.statusCode).toBe(200);
195
+ const preview = JSON.parse(res.body).preview as LinkPreviewPayload;
196
+ expect(preview.title).toBe("www.zhihu.com");
197
+ expect(preview.iconUrl).toMatch(/^\/friday-next\/files\/.+\.ico$/);
198
+ expect(preview.imageUrl).toBeNull();
199
+ });
200
+
201
+ it("502 fetch_failed for a dead domain (page and favicon both fail)", async () => {
202
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
203
+ const res = await invoke(makeReq("https://dead.example.com/x"));
204
+ expect(res.statusCode).toBe(502);
205
+ expect(JSON.parse(res.body).error).toBe("fetch_failed");
206
+ });
207
+
208
+ it("502 fetch_failed on non-2xx and non-HTML responses", async () => {
209
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 })));
210
+ expect((await invoke(makeReq("https://example.com/down"))).statusCode).toBe(502);
211
+
212
+ resetLinkPreviewCacheForTest();
213
+ vi.stubGlobal(
214
+ "fetch",
215
+ vi.fn(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })),
216
+ );
217
+ expect((await invoke(makeReq("https://example.com/api"))).statusCode).toBe(502);
218
+ });
219
+
220
+ it("serves the second request from cache without refetching", async () => {
221
+ const fetchMock = vi.fn(async () => new Response(`<meta property="og:title" content="Cached">`, {
222
+ status: 200,
223
+ headers: { "content-type": "text/html" },
224
+ }));
225
+ vi.stubGlobal("fetch", fetchMock);
226
+ await invoke(makeReq("https://example.com/cached"));
227
+ const afterFirst = fetchMock.mock.calls.length;
228
+ const second = await invoke(makeReq("https://example.com/cached"));
229
+ expect(second.statusCode).toBe(200);
230
+ expect(JSON.parse(second.body).preview.title).toBe("Cached");
231
+ expect(fetchMock).toHaveBeenCalledTimes(afterFirst); // cached → no extra network
232
+ });
233
+
234
+ it("negative-caches failures", async () => {
235
+ const fetchMock = vi.fn(async () => new Response("nope", { status: 500 }));
236
+ vi.stubGlobal("fetch", fetchMock);
237
+ await invoke(makeReq("https://example.com/flaky"));
238
+ const afterFirst = fetchMock.mock.calls.length;
239
+ await invoke(makeReq("https://example.com/flaky"));
240
+ expect(fetchMock).toHaveBeenCalledTimes(afterFirst); // negative-cached → no refetch
241
+ });
242
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * GET /friday-next/link-preview?url=<percent-encoded http(s) URL>
3
+ *
4
+ * Returns Open Graph metadata for a page link so the app can render a preview card without
5
+ * ever contacting the third-party site itself. Cover images are re-hosted under
6
+ * /friday-next/files/ by the preview service.
7
+ */
8
+
9
+ import type { IncomingMessage, ServerResponse } from "node:http";
10
+ import { getLinkPreview, type LinkPreviewError } from "../../link-preview/preview-service.js";
11
+ import { extractBearerToken } from "../middleware/auth.js";
12
+
13
+ const ERROR_STATUS: Record<LinkPreviewError, number> = {
14
+ invalid_url: 400,
15
+ blocked_url: 403,
16
+ no_metadata: 422,
17
+ fetch_failed: 502,
18
+ };
19
+
20
+ export async function handleLinkPreview(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
21
+ if (req.method !== "GET") {
22
+ res.statusCode = 405;
23
+ res.setHeader("Content-Type", "application/json");
24
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
25
+ return true;
26
+ }
27
+ if (!extractBearerToken(req)) {
28
+ res.statusCode = 401;
29
+ res.setHeader("Content-Type", "application/json");
30
+ res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
31
+ return true;
32
+ }
33
+
34
+ const url = new URL(req.url ?? "/", "http://localhost").searchParams.get("url")?.trim();
35
+ if (!url) {
36
+ res.statusCode = 400;
37
+ res.setHeader("Content-Type", "application/json");
38
+ res.end(JSON.stringify({ ok: false, error: "invalid_url" }));
39
+ return true;
40
+ }
41
+
42
+ const result = await getLinkPreview(url);
43
+ res.statusCode = result.ok ? 200 : ERROR_STATUS[result.error];
44
+ res.setHeader("Content-Type", "application/json");
45
+ res.end(JSON.stringify(result));
46
+ return true;
47
+ }
@@ -20,6 +20,7 @@ import { handleHistorySessions } from "./handlers/history-sessions.js";
20
20
  import { handleHistoryMessages } from "./handlers/history-messages.js";
21
21
  import { handleHistorySetTitle } from "./handlers/history-set-title.js";
22
22
  import { handleStatus } from "./handlers/status.js";
23
+ import { handleLinkPreview } from "./handlers/link-preview.js";
23
24
  import { handleHealth } from "./handlers/health.js";
24
25
  import { handlePluginInfo } from "./handlers/plugin-info.js";
25
26
  import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
@@ -106,6 +107,11 @@ async function handleFridayNextRoute(
106
107
  return await handleHistorySetTitle(req, res);
107
108
  }
108
109
 
110
+ // Route: GET /friday-next/link-preview?url=... (Open Graph metadata for preview cards)
111
+ if (req.method === "GET" && pathname === "/friday-next/link-preview") {
112
+ return await handleLinkPreview(req, res);
113
+ }
114
+
109
115
  // Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
110
116
  if (req.method === "GET" && pathname === "/friday-next/health") {
111
117
  return await handleHealth(req, res);