@jant/core 0.6.8 → 0.6.9

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 (62) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-C-jxWmAV.js} +12324 -12157
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-C6peCkkD.css → client-CGf2m3qp.css} +1 -1
  6. package/dist/client/_assets/{client-CXnEhyyv.js → client-DWy1LEEk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-Blg-a5Ep.js} +180 -162
  8. package/dist/{export-Be082J0n.js → export-C2DIB7mm.js} +2 -2
  9. package/dist/{github-sync-_kPWM4m9.js → github-sync-7XQ5ZM6z.js} +2 -2
  10. package/dist/{github-sync-D1Cw8mOY.js → github-sync-BEFCfLKK.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +56 -18
  18. package/src/client/components/settings-types.ts +11 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  21. package/src/client/tiptap/bubble-menu.ts +37 -4
  22. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  23. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  24. package/src/db/migrations/meta/_journal.json +7 -0
  25. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  26. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  27. package/src/db/migrations/pg/meta/_journal.json +7 -0
  28. package/src/db/pg/schema.ts +36 -0
  29. package/src/db/schema.ts +36 -0
  30. package/src/i18n/__tests__/middleware.test.ts +46 -0
  31. package/src/i18n/locales/settings/en.po +25 -10
  32. package/src/i18n/locales/settings/en.ts +1 -1
  33. package/src/i18n/locales/settings/zh-Hans.po +25 -10
  34. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hant.po +25 -10
  36. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  37. package/src/i18n/middleware.ts +17 -8
  38. package/src/i18n/supported-locales.ts +5 -4
  39. package/src/lib/ids.ts +1 -0
  40. package/src/lib/resolve-config.ts +1 -0
  41. package/src/lib/upload.ts +14 -0
  42. package/src/routes/api/__tests__/settings.test.ts +1 -4
  43. package/src/routes/api/__tests__/upload.test.ts +2 -0
  44. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  45. package/src/routes/api/settings.ts +2 -1
  46. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  47. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  48. package/src/routes/dash/settings.tsx +15 -2
  49. package/src/services/__tests__/media.test.ts +191 -30
  50. package/src/services/__tests__/settings.test.ts +55 -0
  51. package/src/services/bootstrap.ts +7 -0
  52. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  53. package/src/services/media.ts +169 -42
  54. package/src/services/settings.ts +49 -15
  55. package/src/services/upload-session.ts +13 -3
  56. package/src/styles/tokens.css +6 -4
  57. package/src/types/bindings.ts +1 -0
  58. package/src/types/config.ts +13 -0
  59. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  60. package/src/ui/layouts/BaseLayout.tsx +1 -0
  61. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  62. package/dist/app-DaxS_Cz-.js +0 -6
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
2
2
  import { describe, it, expect, beforeEach, vi } from "vitest";
3
+ import { eq } from "drizzle-orm";
3
4
  import {
4
5
  createTestDatabase,
5
6
  DEFAULT_TEST_SITE_ID,
@@ -7,6 +8,7 @@ import {
7
8
  import { createMediaService } from "../media.js";
8
9
  import { createPostService } from "../post.js";
9
10
  import type { Database } from "../../db/index.js";
11
+ import { storagePurge } from "../../db/schema.js";
10
12
  import { MediaQuotaExceededError } from "../../lib/errors.js";
11
13
  import { now } from "../../lib/time.js";
12
14
 
@@ -55,9 +57,21 @@ function createMockStorage() {
55
57
  async delete(key: string) {
56
58
  files.delete(key);
57
59
  },
60
+ async copy(sourceKey: string, destKey: string) {
61
+ const file = files.get(sourceKey);
62
+ if (file) files.set(destKey, { ...file });
63
+ },
58
64
  };
59
65
  }
60
66
 
67
+ /** Storage mock without server-side copy (e.g. R2 Workers binding). */
68
+ function createNoCopyStorage() {
69
+ const full = createMockStorage();
70
+ const { copy: _copy, ...rest } = full;
71
+ void _copy;
72
+ return rest;
73
+ }
74
+
61
75
  describe("MediaService", () => {
62
76
  let db: Database;
63
77
  let mediaService: ReturnType<typeof createMediaService>;
@@ -504,7 +518,7 @@ describe("MediaService", () => {
504
518
  });
505
519
 
506
520
  describe("delete for text attachments", () => {
507
- it("removes the single .md storage object", async () => {
521
+ it("moves the .md object to trash and frees the original key", async () => {
508
522
  const storage = createMockStorage();
509
523
  const media = await mediaService.createTextAttachment(
510
524
  {
@@ -522,13 +536,18 @@ describe("MediaService", () => {
522
536
 
523
537
  await mediaService.delete(media.id, storage);
524
538
 
525
- expect(storage.files.size).toBe(0);
539
+ // Row gone and the original (public) key freed immediately; the bytes
540
+ // live on under a trash/ key, recoverable until purge.
541
+ expect(await mediaService.getById(media.id)).toBeNull();
526
542
  expect(storage.files.has(media.storageKey)).toBe(false);
543
+ expect(
544
+ [...storage.files.keys()].some((k) => k.startsWith("trash/")),
545
+ ).toBe(true);
527
546
  });
528
547
  });
529
548
 
530
549
  describe("deleteByIds for text attachments", () => {
531
- it("removes the .md file for every text attachment in the batch", async () => {
550
+ it("moves every .md object in the batch to trash", async () => {
532
551
  const storage = createMockStorage();
533
552
  const a = await mediaService.createTextAttachment(
534
553
  { contentFormat: "markdown", content: "first" },
@@ -543,7 +562,14 @@ describe("MediaService", () => {
543
562
 
544
563
  await mediaService.deleteByIds([a.id, b.id], storage);
545
564
 
546
- expect(storage.files.size).toBe(0);
565
+ expect(await mediaService.getById(a.id)).toBeNull();
566
+ expect(await mediaService.getById(b.id)).toBeNull();
567
+ // Originals freed; two trash copies remain until the purge sweep.
568
+ expect(storage.files.has(a.storageKey)).toBe(false);
569
+ expect(storage.files.has(b.storageKey)).toBe(false);
570
+ expect(
571
+ [...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
572
+ ).toBe(2);
547
573
  });
548
574
  });
549
575
 
@@ -1006,6 +1032,30 @@ describe("MediaService", () => {
1006
1032
  expect(ids).toEqual([orphan.id]);
1007
1033
  });
1008
1034
 
1035
+ it("excludes site asset media (avatars, favicons) referenced by settings", async () => {
1036
+ const orphan = await mediaService.create({
1037
+ ...sampleMedia,
1038
+ storageKey: "media/site/files/orphan.jpg",
1039
+ });
1040
+ const avatar = await mediaService.create({
1041
+ ...sampleMedia,
1042
+ storageKey: "media/site/assets/avatar/avatar.png",
1043
+ });
1044
+ const favicon = await mediaService.create({
1045
+ ...sampleMedia,
1046
+ storageKey: "media/site/assets/favicon/apple-touch-icon.png",
1047
+ });
1048
+
1049
+ const ids = await mediaService.listOrphanedMediaIds({
1050
+ before: now() + 1,
1051
+ limit: 10,
1052
+ });
1053
+
1054
+ expect(ids).toEqual([orphan.id]);
1055
+ expect(ids).not.toContain(avatar.id);
1056
+ expect(ids).not.toContain(favicon.id);
1057
+ });
1058
+
1009
1059
  it("excludes media created at or after the cutoff", async () => {
1010
1060
  await mediaService.create({
1011
1061
  ...sampleMedia,
@@ -1305,25 +1355,24 @@ describe("MediaService", () => {
1305
1355
  expect(result).toBe(false);
1306
1356
  });
1307
1357
 
1308
- it("deletes poster from storage when posterKey exists", async () => {
1358
+ it("moves storage objects to trash and frees the original keys", async () => {
1359
+ const storage = createMockStorage();
1360
+ await storage.put("media/vid.mp4", new Uint8Array([1]));
1361
+ await storage.put("media/vid.poster.webp", new Uint8Array([2]));
1309
1362
  const media = await mediaService.create({
1310
1363
  ...sampleMedia,
1311
1364
  storageKey: "media/vid.mp4",
1312
1365
  posterKey: "media/vid.poster.webp",
1313
1366
  });
1314
1367
 
1315
- const deletedKeys: string[] = [];
1316
- const mockStorage = {
1317
- delete: async (key: string) => {
1318
- deletedKeys.push(key);
1319
- },
1320
- put: async () => {},
1321
- get: async () => null,
1322
- };
1368
+ await mediaService.delete(media.id, storage);
1323
1369
 
1324
- await mediaService.delete(media.id, mockStorage as never);
1325
- expect(deletedKeys).toContain("media/vid.mp4");
1326
- expect(deletedKeys).toContain("media/vid.poster.webp");
1370
+ // Originals freed immediately; both objects moved under trash/.
1371
+ expect(storage.files.has("media/vid.mp4")).toBe(false);
1372
+ expect(storage.files.has("media/vid.poster.webp")).toBe(false);
1373
+ expect(
1374
+ [...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
1375
+ ).toBe(2);
1327
1376
  });
1328
1377
  });
1329
1378
 
@@ -1357,7 +1406,11 @@ describe("MediaService", () => {
1357
1406
  expect(await mediaService.getById(m1.id)).not.toBeNull();
1358
1407
  });
1359
1408
 
1360
- it("deletes poster keys from storage", async () => {
1409
+ it("moves storage objects to trash and frees the original keys", async () => {
1410
+ const storage = createMockStorage();
1411
+ await storage.put("media/a.mp4", new Uint8Array([1]));
1412
+ await storage.put("media/a-poster.webp", new Uint8Array([2]));
1413
+ await storage.put("media/b.jpg", new Uint8Array([3]));
1361
1414
  const m1 = await mediaService.create({
1362
1415
  ...sampleMedia,
1363
1416
  storageKey: "media/a.mp4",
@@ -1368,20 +1421,128 @@ describe("MediaService", () => {
1368
1421
  storageKey: "media/b.jpg",
1369
1422
  });
1370
1423
 
1371
- const deletedKeys: string[] = [];
1372
- const mockStorage = {
1373
- delete: async (key: string) => {
1374
- deletedKeys.push(key);
1375
- },
1376
- put: async () => {},
1377
- get: async () => null,
1378
- };
1424
+ await mediaService.deleteByIds([m1.id, m2.id], storage);
1425
+
1426
+ // Three originals freed, three trash copies remain.
1427
+ expect(storage.files.has("media/a.mp4")).toBe(false);
1428
+ expect(storage.files.has("media/a-poster.webp")).toBe(false);
1429
+ expect(storage.files.has("media/b.jpg")).toBe(false);
1430
+ expect(
1431
+ [...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
1432
+ ).toBe(3);
1433
+ });
1434
+ });
1435
+
1436
+ describe("storage purge (recycle window)", () => {
1437
+ const FAR_FUTURE = () => now() + 365 * 24 * 60 * 60;
1438
+
1439
+ it("moves a deleted object to trash with a recorded original_key", async () => {
1440
+ const storage = createMockStorage();
1441
+ await storage.put("media/x.jpg", new Uint8Array([1]));
1442
+ const m = await mediaService.create({
1443
+ ...sampleMedia,
1444
+ storageKey: "media/x.jpg",
1445
+ });
1446
+
1447
+ await mediaService.delete(m.id, storage);
1448
+
1449
+ const rows = await db
1450
+ .select()
1451
+ .from(storagePurge)
1452
+ .where(eq(storagePurge.siteId, DEFAULT_TEST_SITE_ID));
1453
+ expect(rows).toHaveLength(1);
1454
+ expect(rows[0]!.originalKey).toBe("media/x.jpg");
1455
+ expect(rows[0]!.storageKey.startsWith("trash/")).toBe(true);
1456
+ expect(storage.files.has(rows[0]!.storageKey)).toBe(true);
1457
+ expect(storage.files.has("media/x.jpg")).toBe(false);
1458
+ });
1459
+
1460
+ it("purges trashed objects once due (storageKey + posterKey)", async () => {
1461
+ const storage = createMockStorage();
1462
+ await storage.put("media/v.mp4", new Uint8Array([1]));
1463
+ await storage.put("media/v-poster.webp", new Uint8Array([2]));
1464
+ const m = await mediaService.create({
1465
+ ...sampleMedia,
1466
+ storageKey: "media/v.mp4",
1467
+ posterKey: "media/v-poster.webp",
1468
+ });
1469
+ await mediaService.delete(m.id, storage);
1470
+
1471
+ const purged = await mediaService.purgeDueStorageObjects(
1472
+ { before: FAR_FUTURE(), limit: 50, provider: "r2" },
1473
+ storage,
1474
+ );
1475
+
1476
+ expect(purged).toBe(2);
1477
+ expect(storage.files.size).toBe(0);
1478
+ });
1479
+
1480
+ it("retains trash whose recycle window has not elapsed", async () => {
1481
+ const storage = createMockStorage();
1482
+ await storage.put("media/keep.jpg", new Uint8Array([1]));
1483
+ const m = await mediaService.create({
1484
+ ...sampleMedia,
1485
+ storageKey: "media/keep.jpg",
1486
+ });
1487
+ await mediaService.delete(m.id, storage);
1488
+
1489
+ // before = now → the 30-day-out purge_after is not yet due.
1490
+ const purged = await mediaService.purgeDueStorageObjects(
1491
+ { before: now(), limit: 50, provider: "r2" },
1492
+ storage,
1493
+ );
1494
+
1495
+ expect(purged).toBe(0);
1496
+ // Original freed, but the trash copy is retained.
1497
+ expect(storage.files.has("media/keep.jpg")).toBe(false);
1498
+ expect(
1499
+ [...storage.files.keys()].some((k) => k.startsWith("trash/")),
1500
+ ).toBe(true);
1501
+ });
1502
+
1503
+ it("deletes immediately with no recycle when the driver lacks copy", async () => {
1504
+ const storage = createNoCopyStorage();
1505
+ await storage.put("media/nocopy.jpg", new Uint8Array([1]));
1506
+ const m = await mediaService.create({
1507
+ ...sampleMedia,
1508
+ storageKey: "media/nocopy.jpg",
1509
+ });
1510
+
1511
+ await mediaService.delete(m.id, storage);
1512
+
1513
+ // Deleted outright; nothing in trash, nothing queued.
1514
+ expect(storage.files.size).toBe(0);
1515
+ const rows = await db
1516
+ .select()
1517
+ .from(storagePurge)
1518
+ .where(eq(storagePurge.siteId, DEFAULT_TEST_SITE_ID));
1519
+ expect(rows).toHaveLength(0);
1520
+ });
1521
+
1522
+ it("respects the batch limit", async () => {
1523
+ const storage = createMockStorage();
1524
+ await storage.put("media/p1.jpg", new Uint8Array([1]));
1525
+ await storage.put("media/p2.jpg", new Uint8Array([2]));
1526
+ const m1 = await mediaService.create({
1527
+ ...sampleMedia,
1528
+ storageKey: "media/p1.jpg",
1529
+ });
1530
+ const m2 = await mediaService.create({
1531
+ ...sampleMedia,
1532
+ storageKey: "media/p2.jpg",
1533
+ });
1534
+ await mediaService.deleteByIds([m1.id, m2.id], storage);
1535
+
1536
+ const purged = await mediaService.purgeDueStorageObjects(
1537
+ { before: FAR_FUTURE(), limit: 1, provider: "r2" },
1538
+ storage,
1539
+ );
1379
1540
 
1380
- await mediaService.deleteByIds([m1.id, m2.id], mockStorage as never);
1381
- expect(deletedKeys).toContain("media/a.mp4");
1382
- expect(deletedKeys).toContain("media/a-poster.webp");
1383
- expect(deletedKeys).toContain("media/b.jpg");
1384
- expect(deletedKeys).toHaveLength(3);
1541
+ expect(purged).toBe(1);
1542
+ // One trash object purged, one remains.
1543
+ expect(
1544
+ [...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
1545
+ ).toBe(1);
1385
1546
  });
1386
1547
  });
1387
1548
  });
@@ -446,6 +446,61 @@ describe("SettingsService", () => {
446
446
  expect(await settingsService.get("SITE_LANGUAGE")).toBe("en");
447
447
  });
448
448
 
449
+ it("stores a valid dashboard language and reports the change", async () => {
450
+ const result = await settingsService.updateLocaleSettings(
451
+ {
452
+ siteLanguage: "fr",
453
+ dashboardLanguage: "zh-Hant",
454
+ cjkSerifFont: "off",
455
+ timeZone: "UTC",
456
+ },
457
+ { oldLanguage: "fr", oldDashboardLanguage: "" },
458
+ );
459
+
460
+ expect(result.languageChanged).toBe(true);
461
+ expect(await settingsService.get("SITE_LANGUAGE")).toBe("fr");
462
+ expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBe("zh-Hant");
463
+ });
464
+
465
+ it("clears DASHBOARD_LANGUAGE when dashboard language is blank", async () => {
466
+ await settingsService.set("DASHBOARD_LANGUAGE", "zh-Hans");
467
+ await settingsService.updateLocaleSettings(
468
+ {
469
+ siteLanguage: "en",
470
+ dashboardLanguage: "",
471
+ cjkSerifFont: "off",
472
+ timeZone: "UTC",
473
+ },
474
+ { oldLanguage: "en", oldDashboardLanguage: "zh-Hans" },
475
+ );
476
+
477
+ expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBeNull();
478
+ });
479
+
480
+ it("leaves DASHBOARD_LANGUAGE untouched when not provided", async () => {
481
+ await settingsService.set("DASHBOARD_LANGUAGE", "zh-Hans");
482
+ await settingsService.updateLocaleSettings(
483
+ { siteLanguage: "en", cjkSerifFont: "off", timeZone: "UTC" },
484
+ { oldLanguage: "en" },
485
+ );
486
+
487
+ expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBe("zh-Hans");
488
+ });
489
+
490
+ it("rejects a dashboard language Jant is not translated into", async () => {
491
+ await expect(
492
+ settingsService.updateLocaleSettings(
493
+ {
494
+ siteLanguage: "en",
495
+ dashboardLanguage: "fr",
496
+ cjkSerifFont: "off",
497
+ timeZone: "UTC",
498
+ },
499
+ { oldLanguage: "en" },
500
+ ),
501
+ ).rejects.toThrow();
502
+ });
503
+
449
504
  it("updates grouped feed, home, and search settings independently", async () => {
450
505
  await settingsService.updateFeedSettings({ mainRssFeed: "latest" });
451
506
  await settingsService.updateHomeBranding(true);
@@ -13,6 +13,7 @@ import {
13
13
  baseLocale,
14
14
  isValidContentLanguage,
15
15
  normalizeContentLanguage,
16
+ resolveCatalogLocale,
16
17
  } from "../i18n/locales.js";
17
18
  import { createNavItemService } from "./navigation.js";
18
19
  import { createSettingsService } from "./settings.js";
@@ -65,6 +66,12 @@ export function createBootstrapService(
65
66
  ? normalizeContentLanguage(data.siteLanguage)
66
67
  : baseLocale;
67
68
  await settings.set("SITE_LANGUAGE", siteLanguage);
69
+ // Pin the dashboard UI locale to the detected catalog language so it stays
70
+ // stable if the operator later changes the public content language.
71
+ await settings.set(
72
+ "DASHBOARD_LANGUAGE",
73
+ resolveCatalogLocale(siteLanguage),
74
+ );
68
75
  if (data.cjkSerifFont && data.cjkSerifFont !== "off") {
69
76
  await settings.set("CJK_SERIF_FONT", data.cjkSerifFont);
70
77
  }
@@ -1,7 +1,8 @@
1
1
  <!doctype html>
2
2
  {{- $lang := .Site.LanguageCode | default "en" -}}
3
3
  {{- $themeMode := .Site.Params.theme_mode | default "auto" -}}
4
- <html lang="{{ $lang }}"{{ if ne $themeMode "auto" }} data-theme-mode="{{ $themeMode }}"{{ end }}>
4
+ {{- $themeId := .Site.Params.theme_id -}}
5
+ <html lang="{{ $lang }}"{{ with $themeId }} data-theme="{{ . }}"{{ end }}{{ if ne $themeMode "auto" }} data-theme-mode="{{ $themeMode }}"{{ end }}>
5
6
  <head>
6
7
  {{ partial "head.html" . }}
7
8
  </head>
@@ -4,7 +4,18 @@
4
4
  * Handles media upload and management with pluggable storage backends.
5
5
  */
6
6
 
7
- import { eq, desc, inArray, asc, sql, and, or, isNull, lt } from "drizzle-orm";
7
+ import {
8
+ eq,
9
+ desc,
10
+ inArray,
11
+ asc,
12
+ sql,
13
+ and,
14
+ or,
15
+ isNull,
16
+ lt,
17
+ lte,
18
+ } from "drizzle-orm";
8
19
  import { generateKeyBetween } from "fractional-indexing";
9
20
  import { type Database, supportsDrizzleTransaction } from "../db/index.js";
10
21
  import type { DatabaseDialect } from "../db/dialect.js";
@@ -16,11 +27,12 @@ import { createEntityId } from "../lib/ids.js";
16
27
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
17
28
  import { extractBodyText } from "../lib/summary.js";
18
29
  import { now } from "../lib/time.js";
19
- import type { StorageDriver } from "../lib/storage.js";
30
+ import { supportsCopy, type StorageDriver } from "../lib/storage.js";
20
31
  import { renderTiptapJson } from "../lib/tiptap-render.js";
21
32
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
22
33
  import {
23
34
  generateStorageKey,
35
+ SITE_ASSET_STORAGE_KEY_LIKE_PATTERN,
24
36
  toMediaKind,
25
37
  validateUploadFileMetadata,
26
38
  } from "../lib/upload.js";
@@ -45,6 +57,15 @@ import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
45
57
 
46
58
  const DEFAULT_MEDIA_POSITION = "a0";
47
59
 
60
+ /**
61
+ * Recycle-window length for deleted media storage objects. On delete we
62
+ * hard-remove the DB row but defer deleting the underlying storage object
63
+ * until this long afterwards (recorded in `storage_purge`), so an accidental
64
+ * delete is recoverable. R2 has no versioning/undelete, so this is our only
65
+ * safety net for the bytes.
66
+ */
67
+ const STORAGE_PURGE_RETENTION_SECONDS = 30 * 24 * 60 * 60;
68
+
48
69
  /**
49
70
  * MIME type stored on disk and on the `media` row for a Jant-composed text
50
71
  * attachment. Markdown is the canonical source — HTML is rendered on read,
@@ -183,20 +204,38 @@ export interface MediaService {
183
204
  */
184
205
  validateIds(ids: string[]): Promise<void>;
185
206
  /**
186
- * Delete a media record and its storage file.
207
+ * Delete a media record. The DB row is removed immediately. When `storage` is
208
+ * provided and supports server-side copy, the object is moved to a `trash/`
209
+ * key and its original key is deleted now (original URL 404s immediately,
210
+ * bytes recoverable until purge); otherwise the object is deleted immediately.
187
211
  *
188
212
  * @param id - Media record ID
189
- * @param storage - Optional storage driver; when provided the file is deleted from storage
213
+ * @param storage - Storage driver used to retire the object
190
214
  * @returns true if the record existed and was deleted
191
215
  */
192
216
  delete(id: string, storage?: StorageDriver | null): Promise<boolean>;
193
217
  /**
194
- * Delete multiple media records and their storage files.
218
+ * Delete multiple media records. Rows are removed immediately; their storage
219
+ * objects are retired (moved to trash, or deleted) via `storage`.
195
220
  *
196
221
  * @param ids - Media record IDs
197
- * @param storage - Optional storage driver; when provided the files are deleted from storage
222
+ * @param storage - Storage driver used to retire the objects
198
223
  */
199
224
  deleteByIds(ids: string[], storage?: StorageDriver | null): Promise<void>;
225
+ /**
226
+ * Physically delete trashed storage objects whose recycle window has elapsed.
227
+ * Called by the upload cleanup sweep; removes the queue entry for each.
228
+ *
229
+ * @param input.before - Unix-seconds cutoff; only entries with `purgeAfter <= before` are processed
230
+ * @param input.limit - Maximum number of queue entries to process (batch bound)
231
+ * @param input.provider - Only process entries for this storage provider (the active driver)
232
+ * @param storage - Storage driver used to delete the objects
233
+ * @returns Number of storage objects actually deleted
234
+ */
235
+ purgeDueStorageObjects(
236
+ input: { before: number; limit: number; provider: string },
237
+ storage: StorageDriver,
238
+ ): Promise<number>;
200
239
  getByStorageKey(storageKey: string, provider: string): Promise<Media | null>;
201
240
  /**
202
241
  * Return IDs of orphaned media — rows never attached to a post
@@ -291,7 +330,91 @@ export function createMediaService(
291
330
  hostedControlPlane?: HostedControlPlaneClient | null;
292
331
  },
293
332
  ): MediaService {
294
- const { media } = databaseSchema;
333
+ const { media, storagePurge } = databaseSchema;
334
+
335
+ /**
336
+ * Build the recycle-bin key for a deleted object. Each retired object gets a
337
+ * unique `trash/<siteId>/<id>/<basename>` key so trash entries never collide
338
+ * and the original (public) key can be freed immediately.
339
+ */
340
+ function trashStorageKey(id: string, originalKey: string): string {
341
+ const basename = originalKey.split("/").pop() || "object";
342
+ return `trash/${siteId}/${id}/${basename}`;
343
+ }
344
+
345
+ /**
346
+ * Retire the storage object(s) backing the given media rows. When the driver
347
+ * supports server-side copy, each object is moved to a `trash/` key (recorded
348
+ * in `storage_purge`) and its original key is deleted immediately — so the
349
+ * original public URL 404s right away while the bytes stay recoverable until
350
+ * `purge_after`. Drivers without server-side copy (e.g. the R2 Workers
351
+ * binding) fall back to immediate deletion with no recycle window. All
352
+ * storage ops are best-effort so a backend hiccup never blocks the row delete.
353
+ */
354
+ async function retireStorageObjects(
355
+ records: Media[],
356
+ storage: StorageDriver | null | undefined,
357
+ reason: string,
358
+ ): Promise<void> {
359
+ if (!storage || records.length === 0) return;
360
+
361
+ const objects = records.flatMap((record) => {
362
+ const keys = [record.storageKey];
363
+ if (record.posterKey) keys.push(record.posterKey);
364
+ return keys.map((key) => ({ provider: record.provider, key }));
365
+ });
366
+
367
+ if (!supportsCopy(storage)) {
368
+ // No server-side copy: delete immediately (no recycle window).
369
+ await Promise.all(
370
+ objects.map((o) =>
371
+ storage.delete(o.key).catch((err) => {
372
+ // eslint-disable-next-line no-console -- Error logging is intentional
373
+ console.error("Storage delete error:", err);
374
+ }),
375
+ ),
376
+ );
377
+ return;
378
+ }
379
+
380
+ const purgeAfter = now() + STORAGE_PURGE_RETENTION_SECONDS;
381
+ const createdAt = now();
382
+ const entries: Array<typeof storagePurge.$inferInsert> = [];
383
+ for (const o of objects) {
384
+ const id = createEntityId("storagePurge");
385
+ const trashKey = trashStorageKey(id, o.key);
386
+ try {
387
+ await storage.copy(o.key, trashKey);
388
+ } catch (err) {
389
+ // Copy failed: delete the original anyway rather than leave it stranded
390
+ // at its public URL. No recycle entry for this object.
391
+ // eslint-disable-next-line no-console -- Error logging is intentional
392
+ console.error("Storage trash copy error:", err);
393
+ await storage.delete(o.key).catch((e) => {
394
+ // eslint-disable-next-line no-console -- Error logging is intentional
395
+ console.error("Storage delete error:", e);
396
+ });
397
+ continue;
398
+ }
399
+ await storage.delete(o.key).catch((err) => {
400
+ // eslint-disable-next-line no-console -- Error logging is intentional
401
+ console.error("Storage delete error:", err);
402
+ });
403
+ entries.push({
404
+ id,
405
+ siteId,
406
+ provider: o.provider,
407
+ storageKey: trashKey,
408
+ originalKey: o.key,
409
+ reason,
410
+ purgeAfter,
411
+ createdAt,
412
+ });
413
+ }
414
+ if (entries.length > 0) {
415
+ await db.insert(storagePurge).values(entries);
416
+ }
417
+ }
295
418
 
296
419
  async function getLastPosition(postId: string): Promise<string | null> {
297
420
  const rows = await db
@@ -524,6 +647,10 @@ export function createMediaService(
524
647
  eq(media.siteId, siteId),
525
648
  isNull(media.postId),
526
649
  lt(media.createdAt, before),
650
+ // Site assets (avatars, favicons) are stored with postId = null and
651
+ // referenced from site settings, not posts. Exclude them so the
652
+ // reaper never deletes them as abandoned compose uploads.
653
+ sql`${media.storageKey} NOT LIKE ${SITE_ASSET_STORAGE_KEY_LIKE_PATTERN}`,
527
654
  ),
528
655
  )
529
656
  .orderBy(asc(media.createdAt), asc(media.id))
@@ -920,22 +1047,9 @@ export function createMediaService(
920
1047
  const record = await this.getById(id);
921
1048
  if (!record) return false;
922
1049
 
923
- if (storage) {
924
- // Text attachments have a single `.md` object same shape as any
925
- // other media — so no sibling cleanup is needed. Only video/image
926
- // rows carry a companion poster.
927
- await storage.delete(record.storageKey).catch((err) => {
928
- // eslint-disable-next-line no-console -- Error logging is intentional
929
- console.error("Storage delete error:", err);
930
- });
931
- if (record.posterKey) {
932
- await storage.delete(record.posterKey).catch((err) => {
933
- // eslint-disable-next-line no-console -- Error logging is intentional
934
- console.error("Storage delete poster error:", err);
935
- });
936
- }
937
- }
938
-
1050
+ // Move the bytes to trash (recoverable) and free the original key now, so
1051
+ // the original public URL 404s immediately. Then remove the row.
1052
+ await retireStorageObjects([record], storage, "media-delete");
939
1053
  await db
940
1054
  .delete(media)
941
1055
  .where(and(eq(media.siteId, siteId), eq(media.id, id)));
@@ -945,28 +1059,41 @@ export function createMediaService(
945
1059
  async deleteByIds(ids, storage) {
946
1060
  if (ids.length === 0) return;
947
1061
 
948
- if (storage) {
949
- const records = await this.getByIds(ids);
950
- const keys: string[] = [];
951
- for (const record of records) {
952
- keys.push(record.storageKey);
953
- if (record.posterKey) {
954
- keys.push(record.posterKey);
955
- }
956
- }
957
- await Promise.all(
958
- keys.map((key) =>
959
- storage.delete(key).catch((err) => {
960
- // eslint-disable-next-line no-console -- Error logging is intentional
961
- console.error("Storage delete error:", err);
962
- }),
963
- ),
964
- );
965
- }
966
-
1062
+ const records = await this.getByIds(ids);
1063
+ await retireStorageObjects(records, storage, "media-delete");
967
1064
  await db
968
1065
  .delete(media)
969
1066
  .where(and(eq(media.siteId, siteId), inArray(media.id, ids)));
970
1067
  },
1068
+
1069
+ async purgeDueStorageObjects({ before, limit, provider }, storage) {
1070
+ if (limit <= 0) return 0;
1071
+ const dueRows = await db
1072
+ .select()
1073
+ .from(storagePurge)
1074
+ .where(
1075
+ and(
1076
+ eq(storagePurge.siteId, siteId),
1077
+ eq(storagePurge.provider, provider),
1078
+ lte(storagePurge.purgeAfter, before),
1079
+ ),
1080
+ )
1081
+ .orderBy(asc(storagePurge.purgeAfter), asc(storagePurge.id))
1082
+ .limit(limit);
1083
+
1084
+ let purged = 0;
1085
+ for (const row of dueRows) {
1086
+ // `storageKey` is the trash key; it is never referenced by a live media
1087
+ // row, so no liveness check is needed — just delete it and drop the entry.
1088
+ await storage.delete(row.storageKey).catch((err) => {
1089
+ // eslint-disable-next-line no-console -- Error logging is intentional
1090
+ console.error("Storage purge delete error:", err);
1091
+ });
1092
+ await db.delete(storagePurge).where(eq(storagePurge.id, row.id));
1093
+ purged += 1;
1094
+ }
1095
+
1096
+ return purged;
1097
+ },
971
1098
  };
972
1099
  }