@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.
Files changed (114) hide show
  1. package/bin/commands/db/execute-file.js +12 -4
  2. package/bin/commands/db/rehearse.js +2 -2
  3. package/bin/commands/export.js +12 -4
  4. package/bin/commands/import-site.js +99 -305
  5. package/bin/commands/migrate.js +36 -69
  6. package/bin/commands/reset-password.js +10 -4
  7. package/bin/commands/site/export.js +59 -248
  8. package/bin/commands/site/snapshot/export.js +58 -45
  9. package/bin/commands/site/snapshot/import.js +104 -52
  10. package/bin/lib/node-env.js +100 -0
  11. package/bin/lib/runtime-target.js +64 -0
  12. package/bin/lib/site-snapshot.js +185 -54
  13. package/bin/lib/sql-export.js +19 -2
  14. package/dist/{app-C-L7wL6o.js → app-3REcR-3U.js} +332 -190
  15. package/dist/app-B67XOEyo.js +6 -0
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/{client-auth-Dcon89Av.js → client-auth-Ce5WEAVS.js} +236 -183
  18. package/dist/client/_assets/client-s71Js1Cu.css +2 -0
  19. package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
  20. package/dist/github-sync-C593r22F.js +4 -0
  21. package/dist/github-sync-bL1hnx3Q.js +428 -0
  22. package/dist/index.js +3 -2
  23. package/dist/node.js +5 -4
  24. package/package.json +3 -2
  25. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  26. package/src/__tests__/import-site-command.test.ts +18 -0
  27. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  29. package/src/client/components/jant-compose-dialog.ts +7 -6
  30. package/src/client/components/jant-compose-editor.ts +6 -5
  31. package/src/client/components/jant-settings-general.ts +164 -22
  32. package/src/client/components/settings-types.ts +4 -6
  33. package/src/client/random-uuid.ts +23 -0
  34. package/src/client-auth.ts +1 -1
  35. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  36. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  37. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  38. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  39. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  40. package/src/db/migrations/meta/_journal.json +7 -0
  41. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  42. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  43. package/src/db/migrations/pg/meta/_journal.json +7 -0
  44. package/src/db/pg/schema.ts +21 -26
  45. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  46. package/src/db/schema.ts +16 -20
  47. package/src/i18n/__tests__/middleware.test.ts +43 -1
  48. package/src/i18n/coverage.generated.ts +17 -0
  49. package/src/i18n/i18n.ts +18 -2
  50. package/src/i18n/index.ts +3 -0
  51. package/src/i18n/locales/settings/en.po +16 -11
  52. package/src/i18n/locales/settings/en.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  54. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  55. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  56. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  57. package/src/i18n/locales.ts +84 -2
  58. package/src/i18n/middleware.ts +25 -16
  59. package/src/i18n/supported-locales.ts +153 -0
  60. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  61. package/src/lib/__tests__/feed.test.ts +242 -1
  62. package/src/lib/__tests__/post-meta.test.ts +0 -1
  63. package/src/lib/__tests__/view.test.ts +0 -1
  64. package/src/lib/csp-builder.ts +28 -10
  65. package/src/lib/feed.ts +153 -3
  66. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  67. package/src/middleware/auth.ts +1 -1
  68. package/src/middleware/secure-headers.ts +47 -1
  69. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  70. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  71. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  72. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  73. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  74. package/src/node/index.ts +1 -0
  75. package/src/preset.css +8 -2
  76. package/src/routes/api/__tests__/settings.test.ts +3 -2
  77. package/src/routes/api/github-sync.tsx +1 -1
  78. package/src/routes/api/settings.ts +4 -1
  79. package/src/routes/auth/signin.tsx +6 -0
  80. package/src/routes/pages/archive.tsx +4 -2
  81. package/src/services/__tests__/post.test.ts +19 -19
  82. package/src/services/__tests__/search.test.ts +0 -1
  83. package/src/services/__tests__/settings.test.ts +22 -3
  84. package/src/services/bootstrap.ts +7 -3
  85. package/src/services/collection.ts +3 -3
  86. package/src/services/export.ts +0 -3
  87. package/src/services/navigation.ts +0 -2
  88. package/src/services/path.ts +1 -38
  89. package/src/services/post.ts +32 -66
  90. package/src/services/search.ts +0 -6
  91. package/src/services/settings.ts +47 -6
  92. package/src/services/site-admin.ts +6 -1
  93. package/src/styles/ui.css +12 -23
  94. package/src/types/entities.ts +0 -1
  95. package/src/ui/color-themes.ts +1 -1
  96. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  97. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  98. package/src/ui/feed/NoteCard.tsx +1 -11
  99. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  100. package/src/ui/pages/HomePage.tsx +1 -4
  101. package/src/ui/pages/PostPage.tsx +2 -0
  102. package/bin/commands/collections.js +0 -268
  103. package/bin/commands/media.js +0 -302
  104. package/bin/commands/posts.js +0 -262
  105. package/bin/commands/search.js +0 -53
  106. package/bin/commands/settings.js +0 -93
  107. package/bin/lib/http-api.js +0 -223
  108. package/bin/lib/media-upload.js +0 -206
  109. package/dist/app-Hvqe7Ks_.js +0 -5
  110. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  111. package/src/__tests__/bin/content-cli.test.ts +0 -179
  112. package/src/__tests__/bin/media-cli.test.ts +0 -192
  113. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  114. /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 { FeedData, FeedPostView, PostView } from "../../types.js";
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&amp;b=2");
435
+ expect(xml).toContain("Q&amp;A &lt;draft&gt;.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
  });
@@ -23,7 +23,6 @@ const basePost: Post = {
23
23
  previewProvider: null,
24
24
  replyToId: null,
25
25
  threadId: "post-1",
26
- deletedAt: null,
27
26
  publishedAt: 1,
28
27
  lastActivityAt: 1,
29
28
  createdAt: 1,
@@ -57,7 +57,6 @@ function makePost(overrides: Partial<Post> = {}): Post {
57
57
  previewProvider: null,
58
58
  replyToId: null,
59
59
  threadId: UUID_1,
60
- deletedAt: null,
61
60
  publishedAt: 1706745600, // 2024-02-01T00:00:00Z
62
61
  createdAt: 1706745600,
63
62
  updatedAt: 1706745600,
@@ -7,17 +7,18 @@
7
7
  *
8
8
  * Design rationale (Jant-specific):
9
9
  *
10
- * - **Public pages** allow `frame-src 'https:'` and `script-src 'https:'`.
11
- * The site author is the only content source there is no UGC, no
12
- * untrusted writer, no public composer. Locking down third-party scripts
13
- * would block legitimate embeds (YouTube, Letterbird, analytics) and
14
- * deliver no security benefit Jant doesn't already get from being
15
- * single-author. This matches Ghost/WordPress/Bear precedent.
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 dashboard.
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 { isFrameProtected, assetOrigin, uploadConnectSources, isDev } = input;
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 and scripts so
74
- // embeds and code-injection HTML work. Admin pages stay tight.
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, alternateUrl ? permalinkUrl : undefined))}]]></content>
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
  });
@@ -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 dashboard pages.
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) => {