@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.
- package/bin/commands/uploads/cleanup.js +1 -0
- package/dist/{app-9P4rVCe2.js → app-C-jxWmAV.js} +12324 -12157
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-C6peCkkD.css → client-CGf2m3qp.css} +1 -1
- package/dist/client/_assets/{client-CXnEhyyv.js → client-DWy1LEEk.js} +1 -1
- package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-Blg-a5Ep.js} +180 -162
- package/dist/{export-Be082J0n.js → export-C2DIB7mm.js} +2 -2
- package/dist/{github-sync-_kPWM4m9.js → github-sync-7XQ5ZM6z.js} +2 -2
- package/dist/{github-sync-D1Cw8mOY.js → github-sync-BEFCfLKK.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/jant-compose-dialog.ts +12 -0
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/settings/en.po +25 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +25 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +25 -10
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/ids.ts +1 -0
- package/src/lib/resolve-config.ts +1 -0
- package/src/lib/upload.ts +14 -0
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/settings.tsx +15 -2
- package/src/services/__tests__/media.test.ts +191 -30
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/media.ts +169 -42
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +13 -3
- package/src/styles/tokens.css +6 -4
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +13 -0
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- 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("
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
expect(
|
|
1326
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
expect(
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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>
|
package/src/services/media.ts
CHANGED
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* Handles media upload and management with pluggable storage backends.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
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 -
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
}
|