@jant/core 0.6.10 → 0.6.12
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/bin/commands/import-site.js +45 -20
- package/dist/app-B5P1b4TF.js +6 -0
- package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BUxR-E8O.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
- package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
- package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +75 -0
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +8 -2
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-nav-manager.ts +142 -2
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -0
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -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/rehost-images.ts +108 -0
- package/src/i18n/__tests__/fallback.test.ts +24 -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 +54 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +64 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +56 -2
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- 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 +63 -3
- package/src/lib/post-display.ts +14 -8
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +41 -2
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/__tests__/navigation.test.ts +190 -1
- package/src/services/__tests__/post-timeline.test.ts +119 -2
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/layouts/_default/rss.xml +2 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/index.ts +27 -17
- package/src/services/media.ts +111 -0
- package/src/services/navigation.ts +155 -1
- package/src/services/post.ts +24 -7
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +26 -8
- package/src/styles/ui.css +149 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- package/dist/client/_assets/client-xWDl78yi.css +0 -2
|
@@ -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
|
@@ -92,6 +92,49 @@ function getAtomTitle(post: PostView): string {
|
|
|
92
92
|
return post.title || "";
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
interface SinglePostContentOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Inline posts do not have their own Atom entry title/link, so their visible
|
|
98
|
+
* feed HTML must carry title and link metadata that top-level entries expose
|
|
99
|
+
* through Atom fields.
|
|
100
|
+
*/
|
|
101
|
+
inline?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderLinkedText(text: string, href?: string): string {
|
|
105
|
+
const label = escapeXml(text);
|
|
106
|
+
return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderInlinePostHeader(
|
|
110
|
+
post: PostView,
|
|
111
|
+
permalinkUrl?: string,
|
|
112
|
+
): string[] {
|
|
113
|
+
if (post.format === "quote") return [];
|
|
114
|
+
|
|
115
|
+
const parts: string[] = [];
|
|
116
|
+
|
|
117
|
+
if (post.format === "link") {
|
|
118
|
+
const linkUrl = post.url || "";
|
|
119
|
+
const domain = linkUrl ? extractDisplayDomain(linkUrl) || linkUrl : "";
|
|
120
|
+
if (domain) {
|
|
121
|
+
parts.push(`<p>${renderLinkedText(domain, linkUrl)}</p>`);
|
|
122
|
+
}
|
|
123
|
+
if (post.title) {
|
|
124
|
+
parts.push(
|
|
125
|
+
`<h2>${renderLinkedText(post.title, linkUrl || permalinkUrl)}</h2>`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return parts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (post.title) {
|
|
132
|
+
parts.push(`<h2>${renderLinkedText(post.title, permalinkUrl)}</h2>`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return parts;
|
|
136
|
+
}
|
|
137
|
+
|
|
95
138
|
/**
|
|
96
139
|
* Render a star rating as HTML for feed content.
|
|
97
140
|
*/
|
|
@@ -242,10 +285,19 @@ function renderMediaForFeed(
|
|
|
242
285
|
*
|
|
243
286
|
* @param post - Post view data
|
|
244
287
|
* @param permalinkUrl - Absolute permalink URL back to the blog post
|
|
288
|
+
* @param options - Rendering options for top-level versus inline posts
|
|
245
289
|
*/
|
|
246
|
-
function buildSinglePostContent(
|
|
290
|
+
function buildSinglePostContent(
|
|
291
|
+
post: PostView,
|
|
292
|
+
permalinkUrl?: string,
|
|
293
|
+
options: SinglePostContentOptions = {},
|
|
294
|
+
): string {
|
|
247
295
|
const parts: string[] = [];
|
|
248
296
|
|
|
297
|
+
if (options.inline) {
|
|
298
|
+
parts.push(...renderInlinePostHeader(post, permalinkUrl));
|
|
299
|
+
}
|
|
300
|
+
|
|
249
301
|
if (post.format === "quote" && post.quoteText) {
|
|
250
302
|
const sourceName = post.title || "";
|
|
251
303
|
const sourceUrl = post.url || "";
|
|
@@ -316,12 +368,20 @@ function buildFeedContent(
|
|
|
316
368
|
parts.push(
|
|
317
369
|
`<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
|
|
318
370
|
);
|
|
319
|
-
parts.push(buildSinglePostContent(reply, replyPermalink));
|
|
371
|
+
parts.push(buildSinglePostContent(reply, replyPermalink, { inline: true }));
|
|
320
372
|
}
|
|
321
373
|
|
|
322
374
|
return parts.join("\n");
|
|
323
375
|
}
|
|
324
376
|
|
|
377
|
+
function getEntryMedia(post: FeedPostView): MediaView[] {
|
|
378
|
+
const media = [...post.media];
|
|
379
|
+
for (const reply of post.threadReplies ?? []) {
|
|
380
|
+
media.push(...reply.media);
|
|
381
|
+
}
|
|
382
|
+
return media;
|
|
383
|
+
}
|
|
384
|
+
|
|
325
385
|
/**
|
|
326
386
|
* Default Atom feed renderer.
|
|
327
387
|
*
|
|
@@ -354,7 +414,7 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
354
414
|
// One <link rel="enclosure"> per attachment so podcast/offline readers
|
|
355
415
|
// can fetch them. Atom omits length when size is unknown; mimeType is
|
|
356
416
|
// always known from the upload pipeline.
|
|
357
|
-
const enclosureLinks = post
|
|
417
|
+
const enclosureLinks = getEntryMedia(post)
|
|
358
418
|
.map((m) => {
|
|
359
419
|
const lengthAttr =
|
|
360
420
|
m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
|
package/src/lib/post-display.ts
CHANGED
|
@@ -79,9 +79,17 @@ function resolvePostSocialImage(
|
|
|
79
79
|
return undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
interface PostDisplayOptions {
|
|
83
|
+
isAuthenticated?: boolean;
|
|
84
|
+
allowDraft?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canViewPost(post: Post, options: PostDisplayOptions = {}): boolean {
|
|
88
|
+
const isAuthenticated = options.isAuthenticated ?? false;
|
|
83
89
|
if (post.status !== "published") {
|
|
84
|
-
return
|
|
90
|
+
return Boolean(
|
|
91
|
+
post.status === "draft" && isAuthenticated && options.allowDraft,
|
|
92
|
+
);
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
if (post.visibility === "private" && !isAuthenticated) {
|
|
@@ -102,11 +110,10 @@ function canViewPost(post: Post, isAuthenticated: boolean): boolean {
|
|
|
102
110
|
export async function assemblePostCardView(
|
|
103
111
|
c: Context<Env>,
|
|
104
112
|
postId: string,
|
|
105
|
-
options?:
|
|
113
|
+
options?: PostDisplayOptions,
|
|
106
114
|
): Promise<PostView | null> {
|
|
107
115
|
const post = await c.var.services.posts.getById(postId);
|
|
108
|
-
|
|
109
|
-
if (!post || !canViewPost(post, isAuthenticated)) {
|
|
116
|
+
if (!post || !canViewPost(post, options)) {
|
|
110
117
|
return null;
|
|
111
118
|
}
|
|
112
119
|
|
|
@@ -149,15 +156,14 @@ export async function assemblePostCardView(
|
|
|
149
156
|
export async function assemblePostPageDisplay(
|
|
150
157
|
c: Context<Env>,
|
|
151
158
|
postOrId: string | Post,
|
|
152
|
-
options?:
|
|
159
|
+
options?: PostDisplayOptions,
|
|
153
160
|
): Promise<PostPageDisplayData | null> {
|
|
154
161
|
const post =
|
|
155
162
|
typeof postOrId === "string"
|
|
156
163
|
? await c.var.services.posts.getById(postOrId)
|
|
157
164
|
: postOrId;
|
|
158
|
-
const isAuthenticated = options?.isAuthenticated ?? false;
|
|
159
165
|
|
|
160
|
-
if (!post || !canViewPost(post,
|
|
166
|
+
if (!post || !canViewPost(post, options)) {
|
|
161
167
|
return null;
|
|
162
168
|
}
|
|
163
169
|
|
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;
|