@syengup/friday-channel-next 0.1.27 → 0.1.29

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
+ }
@@ -1,10 +1,29 @@
1
1
  /** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
2
2
  export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
3
3
  /**
4
- * Read the install source for this plugin from the live config snapshot.
5
- * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
6
- * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
7
- * never be npm-upgraded (would duplicate-install and break agent media sends).
4
+ * Infer the install source from the loaded plugin's filesystem path (`api.source`).
5
+ *
6
+ * OpenClaw copies non-dev installs (npm/archive/clawhub/git) into
7
+ * `~/.openclaw/npm/projects/<hash>/node_modules/...`, whereas a dev install
8
+ * (`load.paths` / `plugins install --link`) is loaded directly from the source
9
+ * checkout. For this plugin's purposes the only distinction that matters is
10
+ * npm-managed (auto-upgradable) vs dev (must never npm-upgrade — see
11
+ * dev-no-duplicate-plugin-install), so anything under the managed projects dir
12
+ * is treated as "npm".
13
+ */
14
+ export declare function classifyInstallSourceFromLoadedPath(loadedPath: string | null | undefined): InstallSource;
15
+ /**
16
+ * Resolve the install source for this plugin (npm vs dev path).
17
+ * Returns "unknown" when it can't be resolved (e.g. runtime not captured).
18
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths / --link) install
19
+ * which must never be npm-upgraded (would duplicate-install and break agent media sends).
20
+ *
21
+ * Resolution order:
22
+ * 1. The explicit `plugins.installs[<id>].source` config record — present on older
23
+ * OpenClaw builds that surface install records in the runtime config snapshot.
24
+ * 2. Fallback: OpenClaw 2026.6.x moved install records out of the config snapshot
25
+ * into a separate registry (~/.openclaw/state.db), leaving `plugins.installs`
26
+ * unset — so infer from the loaded plugin path (`api.source`) instead.
8
27
  */
9
28
  export declare function getInstallSource(): InstallSource;
10
29
  /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
@@ -6,10 +6,33 @@
6
6
  import { getUpgradeRuntime } from "./upgrade-runtime.js";
7
7
  import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
8
8
  /**
9
- * Read the install source for this plugin from the live config snapshot.
10
- * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
11
- * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
12
- * never be npm-upgraded (would duplicate-install and break agent media sends).
9
+ * Infer the install source from the loaded plugin's filesystem path (`api.source`).
10
+ *
11
+ * OpenClaw copies non-dev installs (npm/archive/clawhub/git) into
12
+ * `~/.openclaw/npm/projects/<hash>/node_modules/...`, whereas a dev install
13
+ * (`load.paths` / `plugins install --link`) is loaded directly from the source
14
+ * checkout. For this plugin's purposes the only distinction that matters is
15
+ * npm-managed (auto-upgradable) vs dev (must never npm-upgrade — see
16
+ * dev-no-duplicate-plugin-install), so anything under the managed projects dir
17
+ * is treated as "npm".
18
+ */
19
+ export function classifyInstallSourceFromLoadedPath(loadedPath) {
20
+ if (!loadedPath)
21
+ return "unknown";
22
+ return loadedPath.includes("/.openclaw/npm/projects/") ? "npm" : "path";
23
+ }
24
+ /**
25
+ * Resolve the install source for this plugin (npm vs dev path).
26
+ * Returns "unknown" when it can't be resolved (e.g. runtime not captured).
27
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths / --link) install
28
+ * which must never be npm-upgraded (would duplicate-install and break agent media sends).
29
+ *
30
+ * Resolution order:
31
+ * 1. The explicit `plugins.installs[<id>].source` config record — present on older
32
+ * OpenClaw builds that surface install records in the runtime config snapshot.
33
+ * 2. Fallback: OpenClaw 2026.6.x moved install records out of the config snapshot
34
+ * into a separate registry (~/.openclaw/state.db), leaving `plugins.installs`
35
+ * unset — so infer from the loaded plugin path (`api.source`) instead.
13
36
  */
14
37
  export function getInstallSource() {
15
38
  const rt = getUpgradeRuntime();
@@ -26,11 +49,11 @@ export function getInstallSource() {
26
49
  source === "marketplace") {
27
50
  return source;
28
51
  }
29
- return "unknown";
30
52
  }
31
53
  catch {
32
- return "unknown";
54
+ // fall through to the path-based heuristic
33
55
  }
56
+ return classifyInstallSourceFromLoadedPath(rt.pluginSource);
34
57
  }
35
58
  /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
36
59
  export function semverGreater(a, b) {
@@ -32,6 +32,12 @@ export type UpgradeRuntime = {
32
32
  afterWrite: ConfigAfterWrite;
33
33
  mutate: (draft: unknown) => unknown | void;
34
34
  }) => Promise<unknown>;
35
+ /**
36
+ * Filesystem path of THIS loaded plugin (`api.source`). Used to infer the install
37
+ * source (npm vs dev) on OpenClaw builds (2026.6.x+) that no longer surface
38
+ * `plugins.installs` in the config snapshot. See `getInstallSource`.
39
+ */
40
+ pluginSource: string | undefined;
35
41
  };
36
42
  export declare function setUpgradeRuntime(api: OpenClawPluginApi): void;
37
43
  export declare function getUpgradeRuntime(): UpgradeRuntime | null;
@@ -16,6 +16,7 @@ export function setUpgradeRuntime(api) {
16
16
  throw new Error("runtime.config.mutateConfigFile unavailable");
17
17
  return mutate(params);
18
18
  },
19
+ pluginSource: typeof api.source === "string" ? api.source : undefined,
19
20
  };
20
21
  }
21
22
  export function getUpgradeRuntime() {
@@ -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.29";
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.29",
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);