@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,9 +1,8 @@
1
- import { createHash } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
3
 
4
4
  export const SNAPSHOT_FORMAT = "jant-site-snapshot";
5
5
  export const SNAPSHOT_VERSION = 1;
6
- export const SNAPSHOT_SCOPE = "content";
7
6
 
8
7
  export const SNAPSHOT_TABLES = [
9
8
  "site_setting",
@@ -47,11 +46,6 @@ export const SNAPSHOT_SETTING_KEYS = [
47
46
  "NOINDEX",
48
47
  ];
49
48
 
50
- export const SNAPSHOT_STORAGE_SETTING_KEYS = [
51
- "SITE_AVATAR",
52
- "SITE_FAVICON_APPLE_TOUCH",
53
- ];
54
-
55
49
  function escapeSqlString(value) {
56
50
  return String(value).replaceAll("'", "''");
57
51
  }
@@ -143,16 +137,6 @@ export function buildSnapshotStorageQuery(siteId) {
143
137
  WHERE "poster_key" IS NOT NULL
144
138
  AND "site_id" = '${escapeSqlString(siteId)}'
145
139
  AND trim("poster_key") <> ''
146
-
147
- UNION ALL
148
-
149
- SELECT
150
- "value" AS "key",
151
- NULL AS "contentType"
152
- FROM "site_setting"
153
- WHERE "key" IN (${quoteList(SNAPSHOT_STORAGE_SETTING_KEYS)})
154
- AND "site_id" = '${escapeSqlString(siteId)}'
155
- AND trim("value") <> ''
156
140
  )
157
141
  WHERE "key" IS NOT NULL
158
142
  AND trim("key") <> ''
@@ -191,19 +175,24 @@ export function snapshotObjectPath(key) {
191
175
  return `objects/${key}`.replace(/\\/g, "/");
192
176
  }
193
177
 
194
- export function buildSnapshotMeta(source, site) {
178
+ export const SNAPSHOT_DIALECTS = ["sqlite", "pg"];
179
+
180
+ export function buildSnapshotMeta(site, options = {}) {
181
+ const dialect = options.dialect;
182
+ if (dialect && !SNAPSHOT_DIALECTS.includes(dialect)) {
183
+ throw new Error(
184
+ `Unsupported snapshot dialect: ${dialect}. Expected one of ${SNAPSHOT_DIALECTS.join(", ")}.`,
185
+ );
186
+ }
187
+
195
188
  return {
196
189
  format: SNAPSHOT_FORMAT,
197
190
  version: SNAPSHOT_VERSION,
198
- scope: SNAPSHOT_SCOPE,
199
- createdAt: new Date().toISOString(),
200
- source,
191
+ ...(dialect ? { dialect } : {}),
201
192
  site: {
202
193
  id: site.id,
203
194
  key: site.key,
204
195
  },
205
- tables: SNAPSHOT_TABLES,
206
- settingKeys: SNAPSHOT_SETTING_KEYS,
207
196
  };
208
197
  }
209
198
 
@@ -224,9 +213,12 @@ export function assertSnapshotMeta(meta) {
224
213
  );
225
214
  }
226
215
 
227
- if (meta.scope !== SNAPSHOT_SCOPE) {
216
+ if (
217
+ meta.dialect !== undefined &&
218
+ !SNAPSHOT_DIALECTS.includes(meta.dialect)
219
+ ) {
228
220
  throw new Error(
229
- `Unsupported snapshot scope: expected ${SNAPSHOT_SCOPE}, got ${String(meta.scope)}`,
221
+ `Snapshot meta has unsupported dialect "${String(meta.dialect)}". Expected one of ${SNAPSHOT_DIALECTS.join(", ")}.`,
230
222
  );
231
223
  }
232
224
 
@@ -241,20 +233,39 @@ export function assertSnapshotMeta(meta) {
241
233
  }
242
234
  }
243
235
 
244
- export function assertSnapshotManifest(manifest) {
245
- if (!manifest || typeof manifest !== "object") {
246
- throw new Error("Snapshot storage-manifest.json is missing or invalid.");
247
- }
236
+ /**
237
+ * Read the snapshot's source dialect, if recorded.
238
+ *
239
+ * Older snapshots predate the `dialect` field — those return `undefined` and
240
+ * the caller decides whether to skip the check or refuse with a clear error.
241
+ */
242
+ export function getSnapshotDialect(meta) {
243
+ return SNAPSHOT_DIALECTS.includes(meta?.dialect) ? meta.dialect : undefined;
244
+ }
248
245
 
249
- if (manifest.version !== SNAPSHOT_VERSION) {
250
- throw new Error(
251
- `Unsupported storage manifest version: expected ${SNAPSHOT_VERSION}, got ${String(manifest.version)}`,
252
- );
246
+ /**
247
+ * Refuse to apply a snapshot whose source dialect doesn't match the target.
248
+ *
249
+ * Cross-dialect db.sql is not safe to replay: SQLite and Postgres differ on
250
+ * BLOB literals (`X'...'` vs `'\x...'`), boolean encoding (`0/1` vs `t/f`),
251
+ * `tsvector`/`generated` columns, identifier quoting edge cases, etc. Better
252
+ * to fail at the start of import than mid-way with a cryptic SQL error.
253
+ */
254
+ export function assertSnapshotDialectMatches(meta, targetDialect) {
255
+ const sourceDialect = getSnapshotDialect(meta);
256
+ if (!sourceDialect) {
257
+ return;
253
258
  }
254
259
 
255
- if (!Array.isArray(manifest.objects)) {
260
+ if (sourceDialect !== targetDialect) {
256
261
  throw new Error(
257
- "Snapshot storage-manifest.json must contain an objects array.",
262
+ [
263
+ `Snapshot dialect mismatch: source is ${sourceDialect}, target is ${targetDialect}.`,
264
+ "Snapshot db.sql is dialect-specific (BLOB literals, generated columns, FTS, etc.)",
265
+ "and cannot be replayed across SQLite and Postgres safely.",
266
+ "Use `jant site export <url>` (HTTP, dialect-neutral) to move content between",
267
+ "different DB engines.",
268
+ ].join("\n"),
258
269
  );
259
270
  }
260
271
  }
@@ -301,22 +312,55 @@ export function rewriteSnapshotSiteIdentifiers(
301
312
  return sql.replaceAll(escapedSource, escapedTarget);
302
313
  }
303
314
 
304
- export function remapSnapshotManifestObjects(
305
- manifest,
306
- sourceSiteId,
307
- targetSiteId,
308
- ) {
315
+ export function remapSnapshotObjectKey(key, sourceSiteId, targetSiteId) {
309
316
  if (!sourceSiteId || sourceSiteId === targetSiteId) {
310
- return manifest;
317
+ return key;
311
318
  }
319
+ return String(key).replaceAll(sourceSiteId, targetSiteId);
320
+ }
312
321
 
313
- return {
314
- ...manifest,
315
- objects: manifest.objects.map((object) => ({
316
- ...object,
317
- key: String(object.key).replaceAll(sourceSiteId, targetSiteId),
318
- })),
319
- };
322
+ /**
323
+ * Walks `<rootDir>/objects/` recursively and returns one entry per file.
324
+ *
325
+ * The relative path inside `objects/` is the storage key as it existed at
326
+ * export time (with forward slashes). If the snapshot was produced by a
327
+ * different site than the import target, callers apply
328
+ * `remapSnapshotObjectKey()` before uploading.
329
+ */
330
+ export async function enumerateSnapshotObjectFiles(rootDir) {
331
+ const objectsRoot = join(rootDir, "objects");
332
+ const entries = [];
333
+
334
+ async function walk(dir) {
335
+ let items;
336
+ try {
337
+ items = await readdir(dir, { withFileTypes: true });
338
+ } catch (error) {
339
+ if (error && error.code === "ENOENT") {
340
+ return;
341
+ }
342
+ throw error;
343
+ }
344
+
345
+ for (const item of items) {
346
+ const fullPath = join(dir, item.name);
347
+ if (item.isDirectory()) {
348
+ await walk(fullPath);
349
+ continue;
350
+ }
351
+
352
+ const key = relative(objectsRoot, fullPath).replace(/\\/g, "/");
353
+ entries.push({
354
+ key,
355
+ filePath: fullPath,
356
+ contentType: guessContentTypeFromKey(key),
357
+ });
358
+ }
359
+ }
360
+
361
+ await walk(objectsRoot);
362
+ entries.sort((a, b) => a.key.localeCompare(b.key));
363
+ return entries;
320
364
  }
321
365
 
322
366
  function prependSiteIdInsert(sql, tableName, siteId) {
@@ -407,6 +451,98 @@ export function rewriteLegacySnapshotSql(sql, siteId) {
407
451
  return `${rewrittenStatements.join(";\n")};\n`;
408
452
  }
409
453
 
454
+ /**
455
+ * Pull the storage_key + poster_key values referenced by every media INSERT
456
+ * inside a snapshot's `db.sql`.
457
+ *
458
+ * The dump format is controlled by `dumpDatabaseToSql`, which produces
459
+ * `INSERT INTO "media" (col, ...) VALUES (val, ...);` statements with single
460
+ * quoted string literals. We use the SQL-aware splitter to chunk the dump,
461
+ * then parse each media INSERT via the column list. This is the import-side
462
+ * "what should be on storage after this snapshot lands" question, used by
463
+ * the preflight check that runs before db.sql is applied.
464
+ */
465
+ export function extractMediaStorageKeysFromDumpSql(sql, sourceSiteId) {
466
+ const uncommented = sql
467
+ .split("\n")
468
+ .filter((line) => !line.trimStart().startsWith("--"))
469
+ .join("\n");
470
+ const statements = splitSqlStatements(uncommented);
471
+ const keys = new Set();
472
+
473
+ for (const statement of statements) {
474
+ const match = statement.match(
475
+ /INSERT\s+INTO\s+"?media"?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)\s*;?\s*$/i,
476
+ );
477
+ if (!match) continue;
478
+
479
+ const colNames = match[1]
480
+ .split(",")
481
+ .map((col) => col.trim().replace(/^"|"$/g, ""));
482
+ const values = parseSqlValueList(match[2]);
483
+ if (values.length !== colNames.length) continue;
484
+
485
+ if (sourceSiteId) {
486
+ const siteIdIdx = colNames.indexOf("site_id");
487
+ if (siteIdIdx >= 0) {
488
+ const siteIdVal = parseSqlScalar(values[siteIdIdx]);
489
+ if (siteIdVal !== sourceSiteId) continue;
490
+ }
491
+ }
492
+
493
+ for (const col of ["storage_key", "poster_key"]) {
494
+ const idx = colNames.indexOf(col);
495
+ if (idx < 0) continue;
496
+ const value = parseSqlScalar(values[idx]);
497
+ if (typeof value === "string" && value.trim() !== "") {
498
+ keys.add(value);
499
+ }
500
+ }
501
+ }
502
+
503
+ return keys;
504
+ }
505
+
506
+ function parseSqlValueList(raw) {
507
+ const values = [];
508
+ let current = "";
509
+ let inString = false;
510
+
511
+ for (let index = 0; index < raw.length; index += 1) {
512
+ const char = raw[index];
513
+ if (char === "'") {
514
+ current += char;
515
+ if (inString && raw[index + 1] === "'") {
516
+ current += "'";
517
+ index += 1;
518
+ continue;
519
+ }
520
+ inString = !inString;
521
+ continue;
522
+ }
523
+ if (char === "," && !inString) {
524
+ values.push(current.trim());
525
+ current = "";
526
+ continue;
527
+ }
528
+ current += char;
529
+ }
530
+
531
+ const tail = current.trim();
532
+ if (tail) values.push(tail);
533
+ return values;
534
+ }
535
+
536
+ function parseSqlScalar(raw) {
537
+ if (raw === undefined) return null;
538
+ const trimmed = raw.trim();
539
+ if (trimmed === "" || /^null$/i.test(trimmed)) return null;
540
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
541
+ return trimmed.slice(1, -1).replaceAll("''", "'");
542
+ }
543
+ return trimmed;
544
+ }
545
+
410
546
  export function buildReplaceSql(siteId) {
411
547
  const statements = [];
412
548
 
@@ -431,11 +567,6 @@ export function normalizeD1Sql(sql) {
431
567
  .trim();
432
568
  }
433
569
 
434
- export async function sha256File(filePath) {
435
- const bytes = await readFile(filePath);
436
- return createHash("sha256").update(bytes).digest("hex");
437
- }
438
-
439
570
  export function guessContentTypeFromKey(key) {
440
571
  const normalized = String(key).toLowerCase();
441
572
 
@@ -136,29 +136,41 @@ export async function getTableColumns(
136
136
  dialect = "sqlite",
137
137
  ) {
138
138
  if (dialect === "pg") {
139
+ // Skip GENERATED ALWAYS columns (e.g. post.search_text, post.search_document):
140
+ // Postgres rejects any explicit value — even `DEFAULT` — on those, so they
141
+ // must not appear in the INSERT column list. The target instance recomputes
142
+ // them from the source columns when the row is inserted.
139
143
  const rows = await queryRunner.query(`
140
144
  SELECT column_name AS name
141
145
  FROM information_schema.columns
142
146
  WHERE table_schema = 'public'
143
147
  AND table_name = ${sqlValue(tableName)}
148
+ AND is_generated = 'NEVER'
144
149
  ORDER BY ordinal_position
145
150
  `);
146
151
 
147
152
  return rows.map((row) => String(row.name));
148
153
  }
149
154
 
155
+ // SQLite doesn't currently use generated columns in this codebase, but
156
+ // table_xinfo (a strict superset of table_info) exposes the `hidden`
157
+ // flag (2 = VIRTUAL generated, 3 = STORED generated) so we filter those
158
+ // out defensively if any are introduced later.
150
159
  const rows = await queryRunner.query(
151
- `PRAGMA table_info(${quoteIdentifier(tableName)})`,
160
+ `PRAGMA table_xinfo(${quoteIdentifier(tableName)})`,
152
161
  );
153
162
 
154
163
  return rows
155
164
  .slice()
156
165
  .sort((left, right) => Number(left.cid) - Number(right.cid))
166
+ .filter((row) => Number(row.hidden) !== 2 && Number(row.hidden) !== 3)
157
167
  .map((row) => String(row.name));
158
168
  }
159
169
 
160
170
  export async function dumpDatabaseToSql(queryRunner, options) {
161
171
  const dialect = options.dialect ?? "sqlite";
172
+ const onProgress =
173
+ typeof options.onProgress === "function" ? options.onProgress : null;
162
174
  const configuredTables = Array.isArray(options.tables)
163
175
  ? sortExportTables(options.tables)
164
176
  : null;
@@ -169,7 +181,12 @@ export async function dumpDatabaseToSql(queryRunner, options) {
169
181
  sql += `-- Exported: ${timestamp}\n`;
170
182
  sql += `-- Source: ${options.source}\n\n`;
171
183
 
172
- for (const tableName of tables) {
184
+ for (const [tableIndex, tableName] of tables.entries()) {
185
+ onProgress?.({
186
+ index: tableIndex + 1,
187
+ total: tables.length,
188
+ table: tableName,
189
+ });
173
190
  const columnNames = await getTableColumns(queryRunner, tableName, dialect);
174
191
  if (columnNames.length === 0) {
175
192
  continue;