@jant/core 0.6.9 → 0.6.11
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.
- package/dist/{app-C-jxWmAV.js → app-CpmficmQ.js} +912 -423
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DWy1LEEk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-DkpSdIDz.js} +101 -99
- package/dist/{export-C2DIB7mm.js → export-Ba7NJImL.js} +93 -93
- package/dist/{github-sync-7XQ5ZM6z.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-BEFCfLKK.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-settings-general.ts +18 -3
- package/src/client/components/settings-types.ts +2 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/link-toolbar.ts +63 -1
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +258 -18
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +258 -18
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +258 -18
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +5 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +6 -3
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +7 -2
- package/src/routes/feed/__tests__/feed.test.ts +58 -19
- package/src/routes/feed/feed.ts +37 -28
- package/src/routes/pages/featured.tsx +17 -0
- package/src/routes/pages/latest.tsx +25 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/services/post.ts +1 -1
- package/src/styles/tokens.css +15 -0
- package/src/styles/ui.css +45 -2
- package/src/ui/__tests__/color-themes.test.ts +2 -2
- package/src/ui/color-themes.ts +32 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
- package/src/ui/dash/settings/GeneralContent.tsx +16 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
- package/src/ui/layouts/BaseLayout.tsx +14 -2
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
- package/dist/app-DqHzOwL5.js +0 -6
- package/dist/client/_assets/client-CGf2m3qp.css +0 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
imageExtensionForMimeType,
|
|
4
|
+
isAllowedSideloadImageType,
|
|
5
|
+
sniffImageMimeType,
|
|
6
|
+
} from "../upload.js";
|
|
7
|
+
|
|
8
|
+
describe("isAllowedSideloadImageType", () => {
|
|
9
|
+
it("accepts the full set of displayable image types", () => {
|
|
10
|
+
for (const type of [
|
|
11
|
+
"image/jpeg",
|
|
12
|
+
"image/png",
|
|
13
|
+
"image/gif",
|
|
14
|
+
"image/webp",
|
|
15
|
+
"image/svg+xml",
|
|
16
|
+
"image/avif",
|
|
17
|
+
"image/bmp",
|
|
18
|
+
"image/x-icon",
|
|
19
|
+
]) {
|
|
20
|
+
expect(isAllowedSideloadImageType(type)).toBe(true);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects non-image and unknown types", () => {
|
|
25
|
+
expect(isAllowedSideloadImageType("text/html")).toBe(false);
|
|
26
|
+
expect(isAllowedSideloadImageType("application/octet-stream")).toBe(false);
|
|
27
|
+
expect(isAllowedSideloadImageType("image/tiff")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("imageExtensionForMimeType", () => {
|
|
32
|
+
it("maps types to extensions", () => {
|
|
33
|
+
expect(imageExtensionForMimeType("image/jpeg")).toBe("jpg");
|
|
34
|
+
expect(imageExtensionForMimeType("image/svg+xml")).toBe("svg");
|
|
35
|
+
expect(imageExtensionForMimeType("image/x-icon")).toBe("ico");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for unsupported types", () => {
|
|
39
|
+
expect(imageExtensionForMimeType("text/html")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("sniffImageMimeType", () => {
|
|
44
|
+
const cases: Array<[string, number[]]> = [
|
|
45
|
+
["image/jpeg", [0xff, 0xd8, 0xff, 0xe0]],
|
|
46
|
+
["image/png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
|
47
|
+
["image/gif", [...new TextEncoder().encode("GIF89a")]],
|
|
48
|
+
[
|
|
49
|
+
"image/webp",
|
|
50
|
+
[0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50],
|
|
51
|
+
],
|
|
52
|
+
["image/bmp", [0x42, 0x4d, 0x00, 0x00]],
|
|
53
|
+
["image/x-icon", [0x00, 0x00, 0x01, 0x00]],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
it.each(cases)("detects %s from magic bytes", (expected, bytes) => {
|
|
57
|
+
expect(sniffImageMimeType(new Uint8Array(bytes))).toBe(expected);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("detects AVIF via the ftyp brand", () => {
|
|
61
|
+
const bytes = new Uint8Array(16);
|
|
62
|
+
bytes.set([...new TextEncoder().encode("ftyp")], 4);
|
|
63
|
+
bytes.set([...new TextEncoder().encode("avif")], 8);
|
|
64
|
+
expect(sniffImageMimeType(bytes)).toBe("image/avif");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects SVG from an <svg> root", () => {
|
|
68
|
+
const svg = new TextEncoder().encode(
|
|
69
|
+
'<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
|
70
|
+
);
|
|
71
|
+
expect(sniffImageMimeType(svg)).toBe("image/svg+xml");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns null for non-image bytes (e.g. HTML)", () => {
|
|
75
|
+
const html = new TextEncoder().encode("<!doctype html><html></html>");
|
|
76
|
+
expect(sniffImageMimeType(html)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { assertPublicHttpUrl, fetchImageBytes } from "../url-fetch.js";
|
|
3
|
+
import { ValidationError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
describe("assertPublicHttpUrl", () => {
|
|
6
|
+
it("accepts public http(s) URLs", () => {
|
|
7
|
+
expect(assertPublicHttpUrl("https://example.com/a.png").href).toBe(
|
|
8
|
+
"https://example.com/a.png",
|
|
9
|
+
);
|
|
10
|
+
expect(assertPublicHttpUrl("http://cdn.example.com/b.jpg").hostname).toBe(
|
|
11
|
+
"cdn.example.com",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each([
|
|
16
|
+
["ftp scheme", "ftp://example.com/a.png"],
|
|
17
|
+
["data scheme", "data:image/png;base64,AAAA"],
|
|
18
|
+
["embedded credentials", "https://user:pass@example.com/a.png"],
|
|
19
|
+
["localhost", "http://localhost/a.png"],
|
|
20
|
+
["sub.localhost", "http://api.localhost/a.png"],
|
|
21
|
+
["0.0.0.0", "http://0.0.0.0/a.png"],
|
|
22
|
+
["loopback v4", "http://127.0.0.1/a.png"],
|
|
23
|
+
["private 10/8", "http://10.0.0.5/a.png"],
|
|
24
|
+
["private 172.16/12", "http://172.16.5.5/a.png"],
|
|
25
|
+
["private 192.168/16", "http://192.168.1.1/a.png"],
|
|
26
|
+
["CGNAT 100.64/10", "http://100.64.0.1/a.png"],
|
|
27
|
+
["link-local / metadata", "http://169.254.169.254/latest/meta-data"],
|
|
28
|
+
["loopback v6", "http://[::1]/a.png"],
|
|
29
|
+
["link-local v6", "http://[fe80::1]/a.png"],
|
|
30
|
+
["ULA v6", "http://[fd00::1]/a.png"],
|
|
31
|
+
["v4-mapped private v6", "http://[::ffff:10.0.0.1]/a.png"],
|
|
32
|
+
])("rejects %s", (_label, url) => {
|
|
33
|
+
expect(() => assertPublicHttpUrl(url)).toThrow(ValidationError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects a non-URL string", () => {
|
|
37
|
+
expect(() => assertPublicHttpUrl("not a url")).toThrow(ValidationError);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("fetchImageBytes", () => {
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function pngBytes(length = 64): Uint8Array {
|
|
47
|
+
const bytes = new Uint8Array(length);
|
|
48
|
+
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it("returns the bytes and content-type on success", async () => {
|
|
53
|
+
const bytes = pngBytes();
|
|
54
|
+
vi.stubGlobal(
|
|
55
|
+
"fetch",
|
|
56
|
+
vi.fn(
|
|
57
|
+
async () =>
|
|
58
|
+
new Response(bytes, {
|
|
59
|
+
headers: { "content-type": "image/png" },
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const result = await fetchImageBytes(new URL("https://example.com/a.png"), {
|
|
65
|
+
maxBytes: 1024,
|
|
66
|
+
timeoutMs: 5000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.contentType).toBe("image/png");
|
|
70
|
+
expect(result.bytes.byteLength).toBe(bytes.byteLength);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("sends a Referer matching the image origin (hotlink protection)", async () => {
|
|
74
|
+
const fetchMock = vi.fn(
|
|
75
|
+
async () =>
|
|
76
|
+
new Response(pngBytes(), { headers: { "content-type": "image/png" } }),
|
|
77
|
+
);
|
|
78
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
79
|
+
|
|
80
|
+
await fetchImageBytes(
|
|
81
|
+
new URL("https://img9.doubanio.com/view/photo/p1.webp"),
|
|
82
|
+
{ maxBytes: 1024, timeoutMs: 5000 },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const headers = fetchMock.mock.calls[0]?.[1]?.headers as
|
|
86
|
+
| Record<string, string>
|
|
87
|
+
| undefined;
|
|
88
|
+
expect(headers?.Referer).toBe("https://img9.doubanio.com/");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects via the Content-Length precheck", async () => {
|
|
92
|
+
vi.stubGlobal(
|
|
93
|
+
"fetch",
|
|
94
|
+
vi.fn(
|
|
95
|
+
async () =>
|
|
96
|
+
new Response(pngBytes(16), {
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "image/png",
|
|
99
|
+
"content-length": "999999",
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
fetchImageBytes(new URL("https://example.com/a.png"), {
|
|
107
|
+
maxBytes: 1024,
|
|
108
|
+
timeoutMs: 5000,
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toThrow(/too large/i);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("aborts when the streamed body exceeds maxBytes (no Content-Length)", async () => {
|
|
114
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
115
|
+
start(controller) {
|
|
116
|
+
controller.enqueue(new Uint8Array(2048));
|
|
117
|
+
controller.close();
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
vi.stubGlobal(
|
|
121
|
+
"fetch",
|
|
122
|
+
vi.fn(
|
|
123
|
+
async () =>
|
|
124
|
+
new Response(stream, {
|
|
125
|
+
headers: { "content-type": "image/png" },
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await expect(
|
|
131
|
+
fetchImageBytes(new URL("https://example.com/a.png"), {
|
|
132
|
+
maxBytes: 1024,
|
|
133
|
+
timeoutMs: 5000,
|
|
134
|
+
}),
|
|
135
|
+
).rejects.toThrow(/too large/i);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("re-validates redirect targets and rejects a hop to a private address", async () => {
|
|
139
|
+
vi.stubGlobal(
|
|
140
|
+
"fetch",
|
|
141
|
+
vi.fn(
|
|
142
|
+
async () =>
|
|
143
|
+
new Response(null, {
|
|
144
|
+
status: 302,
|
|
145
|
+
headers: { location: "http://169.254.169.254/" },
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
fetchImageBytes(new URL("https://example.com/a.png"), {
|
|
152
|
+
maxBytes: 1024,
|
|
153
|
+
timeoutMs: 5000,
|
|
154
|
+
}),
|
|
155
|
+
).rejects.toThrow(ValidationError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("follows a redirect to a public target", async () => {
|
|
159
|
+
const bytes = pngBytes();
|
|
160
|
+
const fetchMock = vi
|
|
161
|
+
.fn()
|
|
162
|
+
.mockResolvedValueOnce(
|
|
163
|
+
new Response(null, {
|
|
164
|
+
status: 302,
|
|
165
|
+
headers: { location: "https://cdn.example.com/final.png" },
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
.mockResolvedValueOnce(
|
|
169
|
+
new Response(bytes, { headers: { "content-type": "image/png" } }),
|
|
170
|
+
);
|
|
171
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
172
|
+
|
|
173
|
+
const result = await fetchImageBytes(new URL("https://example.com/a.png"), {
|
|
174
|
+
maxBytes: 1024,
|
|
175
|
+
timeoutMs: 5000,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
179
|
+
expect(result.bytes.byteLength).toBe(bytes.byteLength);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/lib/feed.ts
CHANGED
|
@@ -187,9 +187,12 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
187
187
|
: "";
|
|
188
188
|
// Prefix the caption with a ▶ glyph as a video cue. A CSS overlay would
|
|
189
189
|
// be stripped by most feed-reader sanitizers, so a plain-text play
|
|
190
|
-
// character is the only marker that renders reliably everywhere.
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
// character is the only marker that renders reliably everywhere. Link
|
|
191
|
+
// only the "Watch video" action label (so it's clickable like the
|
|
192
|
+
// thumbnail and reads cleanly to screen readers); metadata stays outside
|
|
193
|
+
// the link in parens, matching the audio/text/document attachment style.
|
|
194
|
+
const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
|
|
195
|
+
return `<figure><a href="${url}"><img src="${escapeXml(poster)}" alt="${escapeXml(altText || name)}"${dims}/></a><figcaption><a href="${url}">▶ Watch video</a>${metaSuffix}</figcaption></figure>`;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
if (category === "audio") {
|
package/src/lib/upload.ts
CHANGED
|
@@ -277,6 +277,117 @@ export function isImageMimeType(mimeType: string): boolean {
|
|
|
277
277
|
return mimeType.startsWith("image/");
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/** Image MIME types accepted by the remote-image sideload path. */
|
|
281
|
+
const SIDELOAD_IMAGE_MIME_TYPES = new Set<string>(IMAGE_MIME_TYPES);
|
|
282
|
+
|
|
283
|
+
/** Map of sideload-accepted image MIME types to their file extensions. */
|
|
284
|
+
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
|
|
285
|
+
"image/jpeg": "jpg",
|
|
286
|
+
"image/png": "png",
|
|
287
|
+
"image/gif": "gif",
|
|
288
|
+
"image/webp": "webp",
|
|
289
|
+
"image/svg+xml": "svg",
|
|
290
|
+
"image/avif": "avif",
|
|
291
|
+
"image/bmp": "bmp",
|
|
292
|
+
"image/x-icon": "ico",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Whether a MIME type may be rehosted via remote-image sideload.
|
|
297
|
+
*
|
|
298
|
+
* Unlike {@link getStoredUploadPolicy} (which only allows webp/png/jpeg because
|
|
299
|
+
* the normal upload path re-encodes everything to WebP client-side), the
|
|
300
|
+
* sideload path stores the original remote bytes, so it accepts the full set of
|
|
301
|
+
* image formats Jant can display.
|
|
302
|
+
*
|
|
303
|
+
* @param contentType - The MIME type to check
|
|
304
|
+
* @returns Whether the type is an accepted sideload image
|
|
305
|
+
* @example
|
|
306
|
+
* ```ts
|
|
307
|
+
* isAllowedSideloadImageType("image/gif"); // true
|
|
308
|
+
* isAllowedSideloadImageType("text/html"); // false
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export function isAllowedSideloadImageType(contentType: string): boolean {
|
|
312
|
+
return SIDELOAD_IMAGE_MIME_TYPES.has(contentType);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Returns the file extension for a sideload-accepted image MIME type.
|
|
317
|
+
*
|
|
318
|
+
* @param contentType - The image MIME type
|
|
319
|
+
* @returns Extension without a dot, or null if the type isn't sideloadable
|
|
320
|
+
* @example
|
|
321
|
+
* ```ts
|
|
322
|
+
* imageExtensionForMimeType("image/jpeg"); // "jpg"
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
export function imageExtensionForMimeType(contentType: string): string | null {
|
|
326
|
+
return IMAGE_MIME_EXTENSIONS[contentType] ?? null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Identify an image format from its leading bytes (magic numbers), so a remote
|
|
331
|
+
* sideload can trust the actual content rather than the server's content-type
|
|
332
|
+
* header. Recognizes every {@link isAllowedSideloadImageType} format.
|
|
333
|
+
*
|
|
334
|
+
* @param bytes - The leading bytes of the file (≥ ~256 bytes recommended)
|
|
335
|
+
* @returns The detected image MIME type, or null if unrecognized
|
|
336
|
+
* @example
|
|
337
|
+
* ```ts
|
|
338
|
+
* sniffImageMimeType(pngBytes); // "image/png"
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export function sniffImageMimeType(bytes: Uint8Array): string | null {
|
|
342
|
+
if (
|
|
343
|
+
bytes.length >= 3 &&
|
|
344
|
+
bytes[0] === 0xff &&
|
|
345
|
+
bytes[1] === 0xd8 &&
|
|
346
|
+
bytes[2] === 0xff
|
|
347
|
+
) {
|
|
348
|
+
return "image/jpeg";
|
|
349
|
+
}
|
|
350
|
+
if (
|
|
351
|
+
bytes.length >= 8 &&
|
|
352
|
+
bytes[0] === 0x89 &&
|
|
353
|
+
readAscii(bytes, 1, 3) === "PNG"
|
|
354
|
+
) {
|
|
355
|
+
return "image/png";
|
|
356
|
+
}
|
|
357
|
+
if (bytes.length >= 6 && /^GIF8[79]a$/.test(readAscii(bytes, 0, 6))) {
|
|
358
|
+
return "image/gif";
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
bytes.length >= 12 &&
|
|
362
|
+
readAscii(bytes, 0, 4) === "RIFF" &&
|
|
363
|
+
readAscii(bytes, 8, 4) === "WEBP"
|
|
364
|
+
) {
|
|
365
|
+
return "image/webp";
|
|
366
|
+
}
|
|
367
|
+
if (bytes.length >= 12 && readAscii(bytes, 4, 4) === "ftyp") {
|
|
368
|
+
const brand = readAscii(bytes, 8, 4);
|
|
369
|
+
if (brand === "avif" || brand === "avis") return "image/avif";
|
|
370
|
+
}
|
|
371
|
+
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
|
|
372
|
+
return "image/bmp";
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
bytes.length >= 4 &&
|
|
376
|
+
bytes[0] === 0x00 &&
|
|
377
|
+
bytes[1] === 0x00 &&
|
|
378
|
+
bytes[2] === 0x01 &&
|
|
379
|
+
bytes[3] === 0x00
|
|
380
|
+
) {
|
|
381
|
+
return "image/x-icon";
|
|
382
|
+
}
|
|
383
|
+
// SVG is text — look for an <svg> root in the leading bytes.
|
|
384
|
+
const head = new TextDecoder().decode(bytes.subarray(0, 1024)).toLowerCase();
|
|
385
|
+
if (head.includes("<svg")) {
|
|
386
|
+
return "image/svg+xml";
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
280
391
|
export interface ValidateUploadOptions {
|
|
281
392
|
/** When true, only image MIME types are accepted (e.g. for avatar uploads). */
|
|
282
393
|
imagesOnly?: boolean;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe remote URL fetching for server-side image sideloading.
|
|
3
|
+
*
|
|
4
|
+
* When an author pastes an article from another site, its `<img>` tags point at
|
|
5
|
+
* remote URLs. To rehost those images into the site's own storage the server
|
|
6
|
+
* must fetch the bytes itself (a browser `fetch` of a third-party image is
|
|
7
|
+
* blocked by CORS for most hosts). Because the URL comes from pasted HTML it is
|
|
8
|
+
* attacker-influenced, so every fetch passes through an SSRF guard and a bounded
|
|
9
|
+
* reader that caps size and time.
|
|
10
|
+
*
|
|
11
|
+
* Note: DNS is not resolvable from a Cloudflare Worker, so the IP-literal checks
|
|
12
|
+
* here are defense-in-depth for self-hosted (Node) deployments and for URLs that
|
|
13
|
+
* embed a literal address. They are not a substitute for network-level egress
|
|
14
|
+
* controls.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ValidationError } from "./errors.js";
|
|
18
|
+
|
|
19
|
+
/** A browser-like UA — many CDNs serving article images block unknown bots. */
|
|
20
|
+
const FETCH_USER_AGENT =
|
|
21
|
+
"Mozilla/5.0 (compatible; Jant image sideloader) AppleWebKit/537.36";
|
|
22
|
+
|
|
23
|
+
export interface FetchedImage {
|
|
24
|
+
bytes: Uint8Array;
|
|
25
|
+
/** Lowercased content-type with parameters stripped, or null if absent. */
|
|
26
|
+
contentType: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FetchImageBytesOptions {
|
|
30
|
+
/** Reject (and abort) once the body exceeds this many bytes. */
|
|
31
|
+
maxBytes: number;
|
|
32
|
+
/** Abort the whole request after this many milliseconds. */
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
/** Maximum redirect hops to follow (each re-validated). Default 3. */
|
|
35
|
+
maxRedirects?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate that a string is a public http(s) URL safe to fetch server-side.
|
|
40
|
+
*
|
|
41
|
+
* Throws {@link ValidationError} for non-http(s) protocols, embedded
|
|
42
|
+
* credentials, localhost names, and private/loopback/link-local/ULA/CGNAT IP
|
|
43
|
+
* literals (including the `169.254.169.254` cloud-metadata address).
|
|
44
|
+
*
|
|
45
|
+
* @param raw - The candidate URL string
|
|
46
|
+
* @returns The parsed {@link URL}
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const url = assertPublicHttpUrl("https://example.com/photo.jpg");
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function assertPublicHttpUrl(raw: string): URL {
|
|
53
|
+
let url: URL;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new ValidationError("That doesn't look like a valid image URL.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
61
|
+
throw new ValidationError("Only http and https image URLs can be fetched.");
|
|
62
|
+
}
|
|
63
|
+
if (url.username || url.password) {
|
|
64
|
+
throw new ValidationError("Image URLs can't include credentials.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const host = url.hostname.toLowerCase();
|
|
68
|
+
if (
|
|
69
|
+
host === "localhost" ||
|
|
70
|
+
host.endsWith(".localhost") ||
|
|
71
|
+
host === "0.0.0.0" ||
|
|
72
|
+
isPrivateIpv4(host) ||
|
|
73
|
+
isPrivateIpv6(host)
|
|
74
|
+
) {
|
|
75
|
+
throw new ValidationError("That image URL points to a private address.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch a remote image with an SSRF-checked redirect chain, a size cap, and a
|
|
83
|
+
* timeout. Reads the body in chunks and aborts the moment it exceeds `maxBytes`
|
|
84
|
+
* so an untrusted host can't exhaust memory.
|
|
85
|
+
*
|
|
86
|
+
* @param startUrl - A URL already validated by {@link assertPublicHttpUrl}
|
|
87
|
+
* @param options - Size cap, timeout, and redirect budget
|
|
88
|
+
* @returns The raw bytes and the response content-type
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const { bytes, contentType } = await fetchImageBytes(url, {
|
|
92
|
+
* maxBytes: 25 * 1024 * 1024,
|
|
93
|
+
* timeoutMs: 15000,
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export async function fetchImageBytes(
|
|
98
|
+
startUrl: URL,
|
|
99
|
+
options: FetchImageBytesOptions,
|
|
100
|
+
): Promise<FetchedImage> {
|
|
101
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
let url = startUrl;
|
|
107
|
+
let response: Response | null = null;
|
|
108
|
+
|
|
109
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
110
|
+
response = await fetch(url.toString(), {
|
|
111
|
+
method: "GET",
|
|
112
|
+
redirect: "manual",
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
headers: {
|
|
115
|
+
Accept: "image/*,*/*;q=0.8",
|
|
116
|
+
"User-Agent": FETCH_USER_AGENT,
|
|
117
|
+
// Many CDNs (Douban, WeChat, etc.) reject hotlinked image requests
|
|
118
|
+
// that lack a Referer. Sending the image's own origin satisfies the
|
|
119
|
+
// common "referer must be same-site" hotlink check.
|
|
120
|
+
Referer: `${url.origin}/`,
|
|
121
|
+
},
|
|
122
|
+
}).catch((error) => {
|
|
123
|
+
if (controller.signal.aborted) {
|
|
124
|
+
throw new ValidationError("Timed out fetching the image.");
|
|
125
|
+
}
|
|
126
|
+
throw new ValidationError(
|
|
127
|
+
error instanceof Error
|
|
128
|
+
? `Couldn't fetch the image: ${error.message}`
|
|
129
|
+
: "Couldn't fetch the image.",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (response.status >= 300 && response.status < 400) {
|
|
134
|
+
const location = response.headers.get("location");
|
|
135
|
+
if (!location) break; // No target — fall through to the (failing) checks.
|
|
136
|
+
if (hop === maxRedirects) {
|
|
137
|
+
throw new ValidationError("Too many redirects fetching the image.");
|
|
138
|
+
}
|
|
139
|
+
// Re-validate every hop so a redirect can't escape the SSRF guard.
|
|
140
|
+
url = assertPublicHttpUrl(new URL(location, url).toString());
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!response) {
|
|
147
|
+
throw new ValidationError("Couldn't fetch the image.");
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new ValidationError(
|
|
151
|
+
`Couldn't fetch the image (HTTP ${response.status}).`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const declared = Number(response.headers.get("content-length"));
|
|
156
|
+
if (Number.isFinite(declared) && declared > options.maxBytes) {
|
|
157
|
+
throw new ValidationError("That image is too large.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const contentType = normalizeContentType(
|
|
161
|
+
response.headers.get("content-type"),
|
|
162
|
+
);
|
|
163
|
+
const bytes = await readBounded(response, options.maxBytes);
|
|
164
|
+
return { bytes, contentType };
|
|
165
|
+
} finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readBounded(
|
|
171
|
+
response: Response,
|
|
172
|
+
maxBytes: number,
|
|
173
|
+
): Promise<Uint8Array> {
|
|
174
|
+
const body = response.body;
|
|
175
|
+
if (!body) {
|
|
176
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
177
|
+
if (buffer.byteLength > maxBytes) {
|
|
178
|
+
throw new ValidationError("That image is too large.");
|
|
179
|
+
}
|
|
180
|
+
return buffer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const reader = body.getReader();
|
|
184
|
+
const chunks: Uint8Array[] = [];
|
|
185
|
+
let total = 0;
|
|
186
|
+
for (;;) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) break;
|
|
189
|
+
if (!value) continue;
|
|
190
|
+
total += value.byteLength;
|
|
191
|
+
if (total > maxBytes) {
|
|
192
|
+
await reader.cancel().catch(() => {});
|
|
193
|
+
throw new ValidationError("That image is too large.");
|
|
194
|
+
}
|
|
195
|
+
chunks.push(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const out = new Uint8Array(total);
|
|
199
|
+
let offset = 0;
|
|
200
|
+
for (const chunk of chunks) {
|
|
201
|
+
out.set(chunk, offset);
|
|
202
|
+
offset += chunk.byteLength;
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeContentType(raw: string | null): string | null {
|
|
208
|
+
if (!raw) return null;
|
|
209
|
+
const type = raw.split(";")[0]?.trim().toLowerCase();
|
|
210
|
+
return type || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* True when a canonicalized IPv4 dotted-quad host is in a private, loopback,
|
|
215
|
+
* link-local, CGNAT, or otherwise non-public range. The WHATWG URL parser
|
|
216
|
+
* already normalizes decimal/octal/hex IPv4 forms to dotted-quad, so checking
|
|
217
|
+
* `url.hostname` is sufficient.
|
|
218
|
+
*/
|
|
219
|
+
function isPrivateIpv4(host: string): boolean {
|
|
220
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
221
|
+
if (!match) return false;
|
|
222
|
+
const octets = match.slice(1, 5).map(Number);
|
|
223
|
+
if (octets.some((part) => part > 255)) return true; // Malformed → unsafe.
|
|
224
|
+
const a = octets[0] ?? 0;
|
|
225
|
+
const b = octets[1] ?? 0;
|
|
226
|
+
if (a === 0) return true; // 0.0.0.0/8 "this" network
|
|
227
|
+
if (a === 10) return true; // 10/8 private
|
|
228
|
+
if (a === 127) return true; // 127/8 loopback
|
|
229
|
+
if (a === 169 && b === 254) return true; // 169.254/16 link-local (metadata)
|
|
230
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 private
|
|
231
|
+
if (a === 192 && b === 168) return true; // 192.168/16 private
|
|
232
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
|
|
233
|
+
if (a === 255 && b === 255) return true; // broadcast
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** True when an IPv6 host (bracketed or bare) is loopback/link-local/ULA/mapped-private. */
|
|
238
|
+
function isPrivateIpv6(host: string): boolean {
|
|
239
|
+
let inner = host;
|
|
240
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
241
|
+
inner = inner.slice(1, -1);
|
|
242
|
+
}
|
|
243
|
+
if (!inner.includes(":")) return false;
|
|
244
|
+
const lower = inner.toLowerCase();
|
|
245
|
+
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
|
|
246
|
+
|
|
247
|
+
// IPv4-mapped, dotted form (::ffff:1.2.3.4).
|
|
248
|
+
const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
249
|
+
if (dotted?.[1]) return isPrivateIpv4(dotted[1]);
|
|
250
|
+
|
|
251
|
+
// IPv4-mapped, hex form (::ffff:a00:1) — what the URL parser normalizes to.
|
|
252
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
253
|
+
if (hex) {
|
|
254
|
+
const hi = parseInt(hex[1] ?? "0", 16);
|
|
255
|
+
const lo = parseInt(hex[2] ?? "0", 16);
|
|
256
|
+
const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
257
|
+
return isPrivateIpv4(v4);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
|
|
261
|
+
if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local
|
|
262
|
+
return false;
|
|
263
|
+
}
|