@jant/core 0.3.45 → 0.3.47
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/db/execute-file.js +12 -4
- package/bin/commands/db/rehearse.js +2 -2
- package/bin/commands/export.js +12 -4
- package/bin/commands/import-site.js +99 -305
- package/bin/commands/migrate.js +36 -69
- package/bin/commands/reset-password.js +10 -4
- package/bin/commands/site/export.js +59 -248
- package/bin/commands/site/snapshot/export.js +58 -45
- package/bin/commands/site/snapshot/import.js +104 -52
- package/bin/lib/node-env.js +100 -0
- package/bin/lib/runtime-target.js +64 -0
- package/bin/lib/site-snapshot.js +185 -54
- package/bin/lib/sql-export.js +19 -2
- package/dist/{app-C-L7wL6o.js → app-3REcR-3U.js} +332 -190
- package/dist/app-B67XOEyo.js +6 -0
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/{client-auth-Dcon89Av.js → client-auth-Ce5WEAVS.js} +236 -183
- package/dist/client/_assets/client-s71Js1Cu.css +2 -0
- package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
- package/dist/github-sync-C593r22F.js +4 -0
- package/dist/github-sync-bL1hnx3Q.js +428 -0
- package/dist/index.js +3 -2
- package/dist/node.js +5 -4
- package/package.json +3 -2
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/__tests__/import-site-command.test.ts +18 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
- package/src/client/components/jant-compose-dialog.ts +7 -6
- package/src/client/components/jant-compose-editor.ts +6 -5
- package/src/client/components/jant-settings-general.ts +164 -22
- package/src/client/components/settings-types.ts +4 -6
- package/src/client/random-uuid.ts +23 -0
- package/src/client-auth.ts +1 -1
- package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
- package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
- package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
- package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
- package/src/db/migrations/meta/0021_snapshot.json +2121 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
- package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +21 -26
- package/src/db/rehearsal-fixtures/demo-current.json +1 -1
- package/src/db/schema.ts +16 -20
- package/src/i18n/__tests__/middleware.test.ts +43 -1
- package/src/i18n/coverage.generated.ts +17 -0
- package/src/i18n/i18n.ts +18 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/locales/settings/en.po +16 -11
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +17 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +16 -11
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +84 -2
- package/src/i18n/middleware.ts +25 -16
- package/src/i18n/supported-locales.ts +153 -0
- package/src/lib/__tests__/csp-builder.test.ts +19 -2
- package/src/lib/__tests__/feed.test.ts +242 -1
- package/src/lib/__tests__/post-meta.test.ts +0 -1
- package/src/lib/__tests__/view.test.ts +0 -1
- package/src/lib/csp-builder.ts +28 -10
- package/src/lib/feed.ts +153 -3
- package/src/middleware/__tests__/secure-headers.test.ts +89 -0
- package/src/middleware/auth.ts +1 -1
- package/src/middleware/secure-headers.ts +47 -1
- package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
- package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
- package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
- package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
- package/src/node/__tests__/cli-sql-export.test.ts +49 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +8 -2
- package/src/routes/api/__tests__/settings.test.ts +3 -2
- package/src/routes/api/github-sync.tsx +1 -1
- package/src/routes/api/settings.ts +4 -1
- package/src/routes/auth/signin.tsx +6 -0
- package/src/routes/pages/archive.tsx +4 -2
- package/src/services/__tests__/post.test.ts +19 -19
- package/src/services/__tests__/search.test.ts +0 -1
- package/src/services/__tests__/settings.test.ts +22 -3
- package/src/services/bootstrap.ts +7 -3
- package/src/services/collection.ts +3 -3
- package/src/services/export.ts +0 -3
- package/src/services/navigation.ts +0 -2
- package/src/services/path.ts +1 -38
- package/src/services/post.ts +32 -66
- package/src/services/search.ts +0 -6
- package/src/services/settings.ts +47 -6
- package/src/services/site-admin.ts +6 -1
- package/src/styles/ui.css +12 -23
- package/src/types/entities.ts +0 -1
- package/src/ui/color-themes.ts +1 -1
- package/src/ui/dash/settings/GeneralContent.tsx +17 -19
- package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
- package/src/ui/feed/NoteCard.tsx +1 -11
- package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
- package/src/ui/pages/HomePage.tsx +1 -4
- package/src/ui/pages/PostPage.tsx +2 -0
- package/bin/commands/collections.js +0 -268
- package/bin/commands/media.js +0 -302
- package/bin/commands/posts.js +0 -262
- package/bin/commands/search.js +0 -53
- package/bin/commands/settings.js +0 -93
- package/bin/lib/http-api.js +0 -223
- package/bin/lib/media-upload.js +0 -206
- package/dist/app-Hvqe7Ks_.js +0 -5
- package/dist/client/_assets/client-DDs6NzB3.css +0 -2
- package/src/__tests__/bin/content-cli.test.ts +0 -179
- package/src/__tests__/bin/media-cli.test.ts +0 -192
- /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
- /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { defaultFeedRenderer } from "../feed.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
FeedData,
|
|
5
|
+
FeedPostView,
|
|
6
|
+
MediaView,
|
|
7
|
+
PostView,
|
|
8
|
+
} from "../../types.js";
|
|
9
|
+
|
|
10
|
+
function makeMediaView(overrides: Partial<MediaView> = {}): MediaView {
|
|
11
|
+
return {
|
|
12
|
+
id: "med_1",
|
|
13
|
+
url: "https://example.com/media/file.bin",
|
|
14
|
+
thumbnailUrl: "https://example.com/media/file.bin",
|
|
15
|
+
mimeType: "application/octet-stream",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
4
19
|
|
|
5
20
|
function makePostView(overrides: Partial<FeedPostView> = {}): FeedPostView {
|
|
6
21
|
return {
|
|
@@ -206,4 +221,230 @@ describe("feed renderers", () => {
|
|
|
206
221
|
expect(xml).toContain('<time datetime="2026-03-19T12:00:00.000Z">');
|
|
207
222
|
expect(xml).toContain("<p>This is a reply</p>");
|
|
208
223
|
});
|
|
224
|
+
|
|
225
|
+
it("embeds image attachments as figures with alt text caption", () => {
|
|
226
|
+
const post = makePostView({
|
|
227
|
+
bodyHtml: "<p>Look at this.</p>",
|
|
228
|
+
media: [
|
|
229
|
+
makeMediaView({
|
|
230
|
+
id: "med_img",
|
|
231
|
+
url: "https://example.com/media/photo.jpg",
|
|
232
|
+
thumbnailUrl: "https://example.com/media/photo-thumb.jpg",
|
|
233
|
+
mimeType: "image/jpeg",
|
|
234
|
+
altText: "A red bicycle",
|
|
235
|
+
width: 1200,
|
|
236
|
+
height: 800,
|
|
237
|
+
size: 245_000,
|
|
238
|
+
}),
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
242
|
+
|
|
243
|
+
expect(xml).toContain('<a href="https://example.com/media/photo.jpg">');
|
|
244
|
+
expect(xml).toContain(
|
|
245
|
+
'<img src="https://example.com/media/photo.jpg" alt="A red bicycle" width="1200" height="800"/>',
|
|
246
|
+
);
|
|
247
|
+
expect(xml).toContain("<figcaption>A red bicycle</figcaption>");
|
|
248
|
+
expect(xml).toContain(
|
|
249
|
+
'<link rel="enclosure" type="image/jpeg" href="https://example.com/media/photo.jpg" length="245000"',
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("renders video attachments as poster + caption (never inline <video>)", () => {
|
|
254
|
+
const post = makePostView({
|
|
255
|
+
media: [
|
|
256
|
+
makeMediaView({
|
|
257
|
+
id: "med_vid",
|
|
258
|
+
url: "https://example.com/media/clip.mp4",
|
|
259
|
+
thumbnailUrl: "https://example.com/media/clip-thumb.jpg",
|
|
260
|
+
posterUrl: "https://example.com/media/clip-poster.jpg",
|
|
261
|
+
mimeType: "video/mp4",
|
|
262
|
+
durationSeconds: 42,
|
|
263
|
+
size: 1_200_000,
|
|
264
|
+
width: 1920,
|
|
265
|
+
height: 1080,
|
|
266
|
+
}),
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
270
|
+
|
|
271
|
+
expect(xml).not.toContain("<video");
|
|
272
|
+
expect(xml).toContain(
|
|
273
|
+
'<img src="https://example.com/media/clip-poster.jpg"',
|
|
274
|
+
);
|
|
275
|
+
expect(xml).toContain("Watch video · 0:42 · 1.1 MB");
|
|
276
|
+
expect(xml).toContain(
|
|
277
|
+
'<link rel="enclosure" type="video/mp4" href="https://example.com/media/clip.mp4" length="1200000"',
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("renders audio attachments as a labeled link with duration and size", () => {
|
|
282
|
+
const post = makePostView({
|
|
283
|
+
media: [
|
|
284
|
+
makeMediaView({
|
|
285
|
+
id: "med_audio",
|
|
286
|
+
url: "https://example.com/media/song.mp3",
|
|
287
|
+
thumbnailUrl: "https://example.com/media/song.mp3",
|
|
288
|
+
mimeType: "audio/mpeg",
|
|
289
|
+
originalName: "song.mp3",
|
|
290
|
+
durationSeconds: 215,
|
|
291
|
+
size: 5_242_880,
|
|
292
|
+
}),
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
296
|
+
|
|
297
|
+
expect(xml).toContain(
|
|
298
|
+
'<a href="https://example.com/media/song.mp3">📎 [audio/mpeg] song.mp3</a> (3:35 · 5.0 MB)',
|
|
299
|
+
);
|
|
300
|
+
expect(xml).toContain(
|
|
301
|
+
'<link rel="enclosure" type="audio/mpeg" href="https://example.com/media/song.mp3" length="5242880" title="song.mp3"',
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("renders text attachments as a single-line link to the rendered preview with char count", () => {
|
|
306
|
+
const post = makePostView({
|
|
307
|
+
permalink: "/post-1",
|
|
308
|
+
media: [
|
|
309
|
+
makeMediaView({
|
|
310
|
+
id: "med_txt",
|
|
311
|
+
url: "https://example.com/media/notes.md",
|
|
312
|
+
thumbnailUrl: "https://example.com/media/notes.md",
|
|
313
|
+
mimeType: "text/markdown",
|
|
314
|
+
originalName: "notes.md",
|
|
315
|
+
summary: "Outline of the talk: intro, three acts, takeaways.",
|
|
316
|
+
chars: 4200,
|
|
317
|
+
}),
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
321
|
+
|
|
322
|
+
expect(xml).toContain(
|
|
323
|
+
'<a href="https://example.com/post-1/text/med_txt">📎 [text/markdown] notes.md</a> (4200 chars): Outline of the talk: intro, three acts, takeaways.',
|
|
324
|
+
);
|
|
325
|
+
// No multi-line aside / "Read full text" CTA — single line only.
|
|
326
|
+
expect(xml).not.toContain("Read full text");
|
|
327
|
+
expect(xml).not.toContain("<aside>");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("omits the summary suffix when a text attachment has none", () => {
|
|
331
|
+
const post = makePostView({
|
|
332
|
+
permalink: "/post-1",
|
|
333
|
+
media: [
|
|
334
|
+
makeMediaView({
|
|
335
|
+
id: "med_txt_no_summary",
|
|
336
|
+
url: "https://example.com/media/silent.md",
|
|
337
|
+
thumbnailUrl: "https://example.com/media/silent.md",
|
|
338
|
+
mimeType: "text/markdown",
|
|
339
|
+
originalName: "silent.md",
|
|
340
|
+
chars: 50,
|
|
341
|
+
}),
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
345
|
+
|
|
346
|
+
expect(xml).toContain(
|
|
347
|
+
'<a href="https://example.com/post-1/text/med_txt_no_summary">📎 [text/markdown] silent.md</a> (50 chars)</p>',
|
|
348
|
+
);
|
|
349
|
+
expect(xml).not.toContain("(50 chars):");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("falls back to file size when a text attachment has no char count", () => {
|
|
353
|
+
const post = makePostView({
|
|
354
|
+
permalink: "/post-1",
|
|
355
|
+
media: [
|
|
356
|
+
makeMediaView({
|
|
357
|
+
id: "med_txt2",
|
|
358
|
+
url: "https://example.com/media/raw.txt",
|
|
359
|
+
thumbnailUrl: "https://example.com/media/raw.txt",
|
|
360
|
+
mimeType: "text/plain",
|
|
361
|
+
originalName: "raw.txt",
|
|
362
|
+
size: 2048,
|
|
363
|
+
}),
|
|
364
|
+
],
|
|
365
|
+
});
|
|
366
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
367
|
+
|
|
368
|
+
expect(xml).toContain(
|
|
369
|
+
'<a href="https://example.com/post-1/text/med_txt2">📎 [text/plain] raw.txt</a> (2 KB)',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("renders document attachments as a link with size suffix", () => {
|
|
374
|
+
const post = makePostView({
|
|
375
|
+
media: [
|
|
376
|
+
makeMediaView({
|
|
377
|
+
id: "med_pdf",
|
|
378
|
+
url: "https://example.com/media/spec.pdf",
|
|
379
|
+
thumbnailUrl: "https://example.com/media/spec.pdf",
|
|
380
|
+
mimeType: "application/pdf",
|
|
381
|
+
originalName: "spec.pdf",
|
|
382
|
+
size: 524_288,
|
|
383
|
+
}),
|
|
384
|
+
],
|
|
385
|
+
});
|
|
386
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
387
|
+
|
|
388
|
+
expect(xml).toContain(
|
|
389
|
+
'<a href="https://example.com/media/spec.pdf">📎 [application/pdf] spec.pdf</a> (512 KB)',
|
|
390
|
+
);
|
|
391
|
+
expect(xml).toContain(
|
|
392
|
+
'<link rel="enclosure" type="application/pdf" href="https://example.com/media/spec.pdf" length="524288" title="spec.pdf"',
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("strips MIME-type parameters from the attachment label", () => {
|
|
397
|
+
const post = makePostView({
|
|
398
|
+
permalink: "/post-1",
|
|
399
|
+
media: [
|
|
400
|
+
makeMediaView({
|
|
401
|
+
id: "med_html",
|
|
402
|
+
url: "https://example.com/media/note.html",
|
|
403
|
+
thumbnailUrl: "https://example.com/media/note.html",
|
|
404
|
+
mimeType: "text/html; charset=utf-8",
|
|
405
|
+
originalName: "note.html",
|
|
406
|
+
chars: 120,
|
|
407
|
+
}),
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
411
|
+
|
|
412
|
+
// The visible link tag should be cleaned to the bare type
|
|
413
|
+
expect(xml).toContain("[text/html] note.html");
|
|
414
|
+
// The enclosure link still preserves the full canonical MIME type
|
|
415
|
+
expect(xml).toContain('type="text/html; charset=utf-8"');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("escapes XML special characters in media URLs and names", () => {
|
|
419
|
+
const post = makePostView({
|
|
420
|
+
media: [
|
|
421
|
+
makeMediaView({
|
|
422
|
+
id: "med_x",
|
|
423
|
+
url: "https://example.com/media/file.pdf?a=1&b=2",
|
|
424
|
+
thumbnailUrl: "https://example.com/media/file.pdf?a=1&b=2",
|
|
425
|
+
mimeType: "application/pdf",
|
|
426
|
+
originalName: "Q&A <draft>.pdf",
|
|
427
|
+
size: 1024,
|
|
428
|
+
}),
|
|
429
|
+
],
|
|
430
|
+
});
|
|
431
|
+
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
432
|
+
|
|
433
|
+
expect(xml).not.toContain("?a=1&b=2");
|
|
434
|
+
expect(xml).toContain("?a=1&b=2");
|
|
435
|
+
expect(xml).toContain("Q&A <draft>.pdf");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("emits no enclosure links and no media block when post has no media", () => {
|
|
439
|
+
const xml = defaultFeedRenderer(
|
|
440
|
+
makeFeedData(
|
|
441
|
+
makePostView({
|
|
442
|
+
bodyHtml: "<p>Plain text only.</p>",
|
|
443
|
+
}),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
expect(xml).not.toContain('rel="enclosure"');
|
|
448
|
+
expect(xml).not.toContain("<figure>");
|
|
449
|
+
});
|
|
209
450
|
});
|
package/src/lib/csp-builder.ts
CHANGED
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Design rationale (Jant-specific):
|
|
9
9
|
*
|
|
10
|
-
* - **Public pages** allow `frame-src
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* - **Public pages** allow `frame-src`, `script-src`, `style-src`,
|
|
11
|
+
* `font-src`, and `connect-src` to load from any `https:` source. The site
|
|
12
|
+
* author is the only content source — there is no UGC, no untrusted
|
|
13
|
+
* writer, no public composer. Locking these down would block legitimate
|
|
14
|
+
* embeds (YouTube, Letterbird, analytics), giscus's stylesheet, and
|
|
15
|
+
* Google Fonts, and deliver no security benefit Jant doesn't already get
|
|
16
|
+
* from being single-author. This matches Ghost/WordPress/Bear precedent.
|
|
16
17
|
*
|
|
17
18
|
* - **Authoring/auth/API routes** (FRAME_PROTECTED_PATH_PREFIXES) keep the
|
|
18
19
|
* tight policy: only same-origin scripts, no third-party iframes, and
|
|
19
20
|
* `frame-ancestors 'none'`. Compromise of an embed page must not lead
|
|
20
|
-
* into the
|
|
21
|
+
* into the settings pages.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
export interface CspBuildInput {
|
|
@@ -29,6 +30,13 @@ export interface CspBuildInput {
|
|
|
29
30
|
uploadConnectSources: string[];
|
|
30
31
|
/** True in `vite dev` so we add `ws:` to connect-src. */
|
|
31
32
|
isDev: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Add `'unsafe-inline'` to script-src so author-pasted inline `<script>`
|
|
35
|
+
* blocks in customHeadHtml / customBodyEndHtml can execute. Should only be
|
|
36
|
+
* set on public (non-frame-protected) pages, and only when the author has
|
|
37
|
+
* actually configured code injection — see `secureHeadersMiddleware`.
|
|
38
|
+
*/
|
|
39
|
+
allowInlineScript?: boolean;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export interface ContentSecurityPolicyDirectives {
|
|
@@ -54,11 +62,18 @@ function appendUnique(sources: string[], value: string | null): void {
|
|
|
54
62
|
export function buildCspDirectives(
|
|
55
63
|
input: CspBuildInput,
|
|
56
64
|
): ContentSecurityPolicyDirectives {
|
|
57
|
-
const {
|
|
65
|
+
const {
|
|
66
|
+
isFrameProtected,
|
|
67
|
+
assetOrigin,
|
|
68
|
+
uploadConnectSources,
|
|
69
|
+
isDev,
|
|
70
|
+
allowInlineScript,
|
|
71
|
+
} = input;
|
|
58
72
|
|
|
59
73
|
// Base script-src: same-origin, Datastar's `unsafe-eval` for data-on-* /
|
|
60
74
|
// data-signals expressions, blob: for media workers (heic-to, mediabunny).
|
|
61
75
|
const scriptSrc = ["'self'", "'unsafe-eval'", "blob:"];
|
|
76
|
+
if (allowInlineScript) scriptSrc.push("'unsafe-inline'");
|
|
62
77
|
appendUnique(scriptSrc, assetOrigin);
|
|
63
78
|
|
|
64
79
|
const styleSrc = ["'self'", "'unsafe-inline'"];
|
|
@@ -70,12 +85,15 @@ export function buildCspDirectives(
|
|
|
70
85
|
const connectSrc = isDev ? ["'self'", "ws:"] : ["'self'"];
|
|
71
86
|
for (const src of uploadConnectSources) appendUnique(connectSrc, src);
|
|
72
87
|
|
|
73
|
-
// On public (non-admin) pages, allow third-party iframes
|
|
74
|
-
//
|
|
88
|
+
// On public (non-admin) pages, allow third-party iframes, scripts,
|
|
89
|
+
// stylesheets, fonts, and fetch endpoints so embeds and code-injection
|
|
90
|
+
// HTML work. Admin pages stay tight.
|
|
75
91
|
let frameSrc: string[] | undefined;
|
|
76
92
|
if (!isFrameProtected) {
|
|
77
93
|
frameSrc = ["'self'", "https:"];
|
|
78
94
|
appendUnique(scriptSrc, "https:");
|
|
95
|
+
appendUnique(styleSrc, "https:");
|
|
96
|
+
appendUnique(fontSrc, "https:");
|
|
79
97
|
appendUnique(connectSrc, "https:");
|
|
80
98
|
}
|
|
81
99
|
|
package/src/lib/feed.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { FeedData, FeedPostView, PostView } from "../types.js";
|
|
13
|
+
import type { FeedData, FeedPostView, MediaView, PostView } from "../types.js";
|
|
14
14
|
import { extractDisplayDomain } from "./url.js";
|
|
15
|
+
import { getMediaCategory } from "./upload.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Escape special XML characters.
|
|
@@ -100,6 +101,136 @@ function renderRatingHtml(rating: number): string {
|
|
|
100
101
|
return `<p>${filled}${empty} ${rating}/5</p>`;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
function formatFeedBytes(bytes: number): string {
|
|
105
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
106
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
107
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatFeedDuration(seconds: number): string {
|
|
111
|
+
const total = Math.max(0, Math.round(seconds));
|
|
112
|
+
const m = Math.floor(total / 60);
|
|
113
|
+
const s = total % 60;
|
|
114
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getMediaMeta(item: MediaView): string {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
if (item.durationSeconds != null && item.durationSeconds > 0) {
|
|
120
|
+
parts.push(formatFeedDuration(item.durationSeconds));
|
|
121
|
+
}
|
|
122
|
+
if (item.size != null && item.size > 0) {
|
|
123
|
+
parts.push(formatFeedBytes(item.size));
|
|
124
|
+
}
|
|
125
|
+
return parts.join(" · ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Strip MIME type parameters like `; charset=utf-8` so the visible label
|
|
130
|
+
* stays compact (e.g. `text/html` instead of `text/html; charset=utf-8`).
|
|
131
|
+
*/
|
|
132
|
+
function cleanMimeType(mimeType: string): string {
|
|
133
|
+
const semi = mimeType.indexOf(";");
|
|
134
|
+
return (semi >= 0 ? mimeType.slice(0, semi) : mimeType).trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the visible link text for non-visual attachments — paperclip +
|
|
139
|
+
* MIME-type tag + filename. Marks the line clearly as an attachment so it
|
|
140
|
+
* doesn't get mistaken for body text.
|
|
141
|
+
*/
|
|
142
|
+
function buildAttachmentLinkText(
|
|
143
|
+
item: MediaView,
|
|
144
|
+
fallbackName: string,
|
|
145
|
+
): string {
|
|
146
|
+
const name = item.originalName?.trim() || fallbackName;
|
|
147
|
+
const mime = cleanMimeType(item.mimeType);
|
|
148
|
+
return `📎 [${escapeXml(mime)}] ${escapeXml(name)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render a single media attachment as HTML for embedding in feed content.
|
|
153
|
+
*
|
|
154
|
+
* - Images embed as `<figure><a><img/></a><figcaption/></figure>` with alt
|
|
155
|
+
* used as caption when present.
|
|
156
|
+
* - Videos render as a poster thumbnail linked to the file with a caption
|
|
157
|
+
* describing the action — feed reader support for `<video>` is uneven, so
|
|
158
|
+
* we never inline the player.
|
|
159
|
+
* - Audio, text, and document attachments render as plain links with size
|
|
160
|
+
* and duration metadata when known. Text attachments link to the rendered
|
|
161
|
+
* preview page when a post permalink is available.
|
|
162
|
+
*/
|
|
163
|
+
function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
164
|
+
const category = getMediaCategory(item.mimeType);
|
|
165
|
+
const url = escapeXml(item.url);
|
|
166
|
+
const name = item.originalName ?? "";
|
|
167
|
+
const altText = item.altText ?? "";
|
|
168
|
+
const caption = item.altText?.trim() || "";
|
|
169
|
+
const meta = getMediaMeta(item);
|
|
170
|
+
|
|
171
|
+
if (category === "image") {
|
|
172
|
+
const dims =
|
|
173
|
+
item.width && item.height
|
|
174
|
+
? ` width="${item.width}" height="${item.height}"`
|
|
175
|
+
: "";
|
|
176
|
+
const figcaption = caption
|
|
177
|
+
? `<figcaption>${escapeXml(caption)}</figcaption>`
|
|
178
|
+
: "";
|
|
179
|
+
return `<figure><a href="${url}"><img src="${url}" alt="${escapeXml(altText)}"${dims}/></a>${figcaption}</figure>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (category === "video") {
|
|
183
|
+
const poster = item.posterUrl || item.thumbnailUrl;
|
|
184
|
+
const dims =
|
|
185
|
+
item.width && item.height
|
|
186
|
+
? ` width="${item.width}" height="${item.height}"`
|
|
187
|
+
: "";
|
|
188
|
+
const label = `Watch video${meta ? ` · ${meta}` : ""}`;
|
|
189
|
+
return `<figure><a href="${url}"><img src="${escapeXml(poster)}" alt="${escapeXml(altText || name)}"${dims}/></a><figcaption>${escapeXml(label)}</figcaption></figure>`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (category === "audio") {
|
|
193
|
+
const linkText = buildAttachmentLinkText(item, "Audio");
|
|
194
|
+
const suffix = meta ? ` (${escapeXml(meta)})` : "";
|
|
195
|
+
return `<p><a href="${url}">${linkText}</a>${suffix}</p>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (category === "text") {
|
|
199
|
+
const previewHref = postPermalinkUrl
|
|
200
|
+
? escapeXml(`${postPermalinkUrl}/text/${item.id}`)
|
|
201
|
+
: url;
|
|
202
|
+
const linkText = buildAttachmentLinkText(item, "Attached text");
|
|
203
|
+
// Prefer character count over byte size — more meaningful for text.
|
|
204
|
+
const textMeta =
|
|
205
|
+
typeof item.chars === "number" && item.chars > 0
|
|
206
|
+
? `${item.chars} chars`
|
|
207
|
+
: meta;
|
|
208
|
+
const metaSuffix = textMeta ? ` (${escapeXml(textMeta)})` : "";
|
|
209
|
+
const summary = item.summary?.trim() ?? "";
|
|
210
|
+
const summarySuffix = summary ? `: ${escapeXml(summary)}` : "";
|
|
211
|
+
return `<p><a href="${previewHref}">${linkText}</a>${metaSuffix}${summarySuffix}</p>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// document, archive, office, font, 3d, code → plain link
|
|
215
|
+
const linkText = buildAttachmentLinkText(item, "Attachment");
|
|
216
|
+
const suffix = meta ? ` (${escapeXml(meta)})` : "";
|
|
217
|
+
return `<p><a href="${url}">${linkText}</a>${suffix}</p>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Render all media attachments for a post as HTML for embedding in feed
|
|
222
|
+
* content. Returns an empty string when the post has no media.
|
|
223
|
+
*/
|
|
224
|
+
function renderMediaForFeed(
|
|
225
|
+
media: MediaView[],
|
|
226
|
+
postPermalinkUrl?: string,
|
|
227
|
+
): string {
|
|
228
|
+
if (media.length === 0) return "";
|
|
229
|
+
return media
|
|
230
|
+
.map((item) => renderMediaItem(item, postPermalinkUrl))
|
|
231
|
+
.join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
103
234
|
/**
|
|
104
235
|
* Build the HTML content for a single post (root or reply).
|
|
105
236
|
*
|
|
@@ -129,6 +260,11 @@ function buildSinglePostContent(post: PostView, permalinkUrl?: string): string {
|
|
|
129
260
|
parts.push(stripUnsafeFeedHtml(post.bodyHtml));
|
|
130
261
|
}
|
|
131
262
|
|
|
263
|
+
const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
|
|
264
|
+
if (mediaHtml) {
|
|
265
|
+
parts.push(mediaHtml);
|
|
266
|
+
}
|
|
267
|
+
|
|
132
268
|
if (post.rating && post.rating > 0) {
|
|
133
269
|
parts.push(renderRatingHtml(post.rating));
|
|
134
270
|
}
|
|
@@ -209,15 +345,29 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
209
345
|
? `\n <link href="${escapedPermalink}" rel="related"/>`
|
|
210
346
|
: "";
|
|
211
347
|
|
|
348
|
+
// One <link rel="enclosure"> per attachment so podcast/offline readers
|
|
349
|
+
// can fetch them. Atom omits length when size is unknown; mimeType is
|
|
350
|
+
// always known from the upload pipeline.
|
|
351
|
+
const enclosureLinks = post.media
|
|
352
|
+
.map((m) => {
|
|
353
|
+
const lengthAttr =
|
|
354
|
+
m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
|
|
355
|
+
const titleAttr = m.originalName
|
|
356
|
+
? ` title="${escapeXml(m.originalName)}"`
|
|
357
|
+
: "";
|
|
358
|
+
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
|
|
359
|
+
})
|
|
360
|
+
.join("");
|
|
361
|
+
|
|
212
362
|
return `
|
|
213
363
|
<entry>
|
|
214
364
|
<title>${escapeXml(title)}</title>
|
|
215
|
-
<link href="${alternateLink}" rel="alternate"/>${relatedLink}
|
|
365
|
+
<link href="${alternateLink}" rel="alternate"/>${relatedLink}${enclosureLinks}
|
|
216
366
|
<id>${escapedPermalink}</id>
|
|
217
367
|
<published>${publishedAt}</published>
|
|
218
368
|
<updated>${updatedAt}</updated>
|
|
219
369
|
<summary type="text">${escapeXml(summary)}</summary>
|
|
220
|
-
<content type="html"><![CDATA[${escapeCdata(buildFeedContent(post, siteUrl,
|
|
370
|
+
<content type="html"><![CDATA[${escapeCdata(buildFeedContent(post, siteUrl, permalinkUrl))}]]></content>
|
|
221
371
|
</entry>`;
|
|
222
372
|
})
|
|
223
373
|
.join("");
|
|
@@ -135,4 +135,93 @@ describe("secureHeadersMiddleware", () => {
|
|
|
135
135
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
|
136
136
|
expect(csp).toContain("frame-ancestors 'none'");
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it("does not relax script-src when no code injection is configured", async () => {
|
|
140
|
+
const app = new Hono<Env>();
|
|
141
|
+
const settings = {
|
|
142
|
+
get: vi.fn(async () => null as string | null),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
app.use("*", async (c, next) => {
|
|
146
|
+
c.set("services", { settings } as AppVariables["services"]);
|
|
147
|
+
await next();
|
|
148
|
+
});
|
|
149
|
+
app.use("*", secureHeadersMiddleware());
|
|
150
|
+
app.get("/", (c) => c.text("ok"));
|
|
151
|
+
|
|
152
|
+
const response = await app.request("/");
|
|
153
|
+
const csp = response.headers.get("content-security-policy") ?? "";
|
|
154
|
+
|
|
155
|
+
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
|
|
156
|
+
expect(csp).toContain("script-src 'self' 'unsafe-eval' blob: https:");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("relaxes script-src with 'unsafe-inline' when code injection is set", async () => {
|
|
160
|
+
const app = new Hono<Env>();
|
|
161
|
+
const settings = {
|
|
162
|
+
get: vi.fn(async (key: string) => {
|
|
163
|
+
if (key === "CUSTOM_HEAD_HTML")
|
|
164
|
+
return "<script>console.log('hi')</script>";
|
|
165
|
+
return null;
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
app.use("*", async (c, next) => {
|
|
170
|
+
c.set("services", { settings } as AppVariables["services"]);
|
|
171
|
+
await next();
|
|
172
|
+
});
|
|
173
|
+
app.use("*", secureHeadersMiddleware());
|
|
174
|
+
app.get("/", (c) => c.text("ok"));
|
|
175
|
+
|
|
176
|
+
const response = await app.request("/");
|
|
177
|
+
const csp = response.headers.get("content-security-policy");
|
|
178
|
+
|
|
179
|
+
expect(csp).toContain(
|
|
180
|
+
"script-src 'self' 'unsafe-eval' blob: 'unsafe-inline' https:",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps script-src tight on frame-protected paths regardless of injection", async () => {
|
|
185
|
+
const app = new Hono<Env>();
|
|
186
|
+
const settings = {
|
|
187
|
+
get: vi.fn(async (key: string) => {
|
|
188
|
+
if (key === "CUSTOM_HEAD_HTML") return "<script>x()</script>";
|
|
189
|
+
return null;
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
app.use("*", async (c, next) => {
|
|
194
|
+
c.set("services", { settings } as AppVariables["services"]);
|
|
195
|
+
await next();
|
|
196
|
+
});
|
|
197
|
+
app.use("*", secureHeadersMiddleware());
|
|
198
|
+
app.get("/settings", (c) => c.text("ok"));
|
|
199
|
+
|
|
200
|
+
const response = await app.request("/settings");
|
|
201
|
+
const csp = response.headers.get("content-security-policy") ?? "";
|
|
202
|
+
|
|
203
|
+
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
|
|
204
|
+
// Settings lookup should be skipped entirely on frame-protected paths.
|
|
205
|
+
expect(settings.get).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("skips the settings lookup on static asset paths", async () => {
|
|
209
|
+
const app = new Hono<Env>();
|
|
210
|
+
const settings = {
|
|
211
|
+
get: vi.fn(async () => null as string | null),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
app.use("*", async (c, next) => {
|
|
215
|
+
c.set("services", { settings } as AppVariables["services"]);
|
|
216
|
+
await next();
|
|
217
|
+
});
|
|
218
|
+
app.use("*", secureHeadersMiddleware());
|
|
219
|
+
app.get("/media/foo.jpg", (c) => c.text("ok"));
|
|
220
|
+
app.get("/favicon.ico", (c) => c.text("ok"));
|
|
221
|
+
|
|
222
|
+
await app.request("/media/foo.jpg");
|
|
223
|
+
await app.request("/favicon.ico");
|
|
224
|
+
|
|
225
|
+
expect(settings.get).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
138
227
|
});
|
package/src/middleware/auth.ts
CHANGED
|
@@ -113,7 +113,7 @@ function getPostSigninRedirect(requestUrl: string): string | null {
|
|
|
113
113
|
/**
|
|
114
114
|
* Middleware that requires authentication.
|
|
115
115
|
* Redirects to signin page if not authenticated.
|
|
116
|
-
* Session-only — Bearer tokens are not accepted for
|
|
116
|
+
* Session-only — Bearer tokens are not accepted for settings pages.
|
|
117
117
|
*/
|
|
118
118
|
export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
|
|
119
119
|
return async (c, next) => {
|