@jant/core 0.3.46 → 0.3.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/db/execute-file.js +12 -4
- package/bin/commands/db/rehearse.js +2 -2
- package/bin/commands/export.js +12 -4
- package/bin/commands/import-site.js +60 -267
- package/bin/commands/migrate.js +36 -69
- package/bin/commands/reset-password.js +10 -4
- package/bin/commands/site/export.js +59 -248
- package/bin/commands/site/snapshot/export.js +58 -45
- package/bin/commands/site/snapshot/import.js +104 -52
- package/bin/lib/node-env.js +100 -0
- package/bin/lib/runtime-target.js +64 -0
- package/bin/lib/site-snapshot.js +185 -54
- package/bin/lib/sql-export.js +19 -2
- package/dist/{app-DB-P66E5.js → app-3REcR-3U.js} +331 -189
- package/dist/app-B67XOEyo.js +6 -0
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
- package/dist/client/_assets/client-s71Js1Cu.css +2 -0
- package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
- package/dist/github-sync-C593r22F.js +4 -0
- package/dist/github-sync-bL1hnx3Q.js +428 -0
- package/dist/index.js +3 -2
- package/dist/node.js +5 -4
- package/package.json +3 -2
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
- package/src/client/components/jant-settings-general.ts +164 -22
- package/src/client/components/settings-types.ts +4 -6
- package/src/client-auth.ts +1 -1
- package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
- package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
- package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
- package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
- package/src/db/migrations/meta/0021_snapshot.json +2121 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
- package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +21 -26
- package/src/db/rehearsal-fixtures/demo-current.json +1 -1
- package/src/db/schema.ts +16 -20
- package/src/i18n/__tests__/middleware.test.ts +43 -1
- package/src/i18n/coverage.generated.ts +17 -0
- package/src/i18n/i18n.ts +18 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/locales/settings/en.po +16 -11
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +17 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +16 -11
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +84 -2
- package/src/i18n/middleware.ts +25 -16
- package/src/i18n/supported-locales.ts +153 -0
- package/src/lib/__tests__/csp-builder.test.ts +19 -2
- package/src/lib/__tests__/feed.test.ts +242 -1
- package/src/lib/__tests__/post-meta.test.ts +0 -1
- package/src/lib/__tests__/view.test.ts +0 -1
- package/src/lib/csp-builder.ts +28 -10
- package/src/lib/feed.ts +153 -3
- package/src/middleware/__tests__/secure-headers.test.ts +89 -0
- package/src/middleware/auth.ts +1 -1
- package/src/middleware/secure-headers.ts +47 -1
- package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
- package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
- package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
- package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
- package/src/node/__tests__/cli-sql-export.test.ts +49 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +8 -2
- package/src/routes/api/__tests__/settings.test.ts +3 -2
- package/src/routes/api/github-sync.tsx +1 -1
- package/src/routes/api/settings.ts +4 -1
- package/src/routes/auth/signin.tsx +6 -0
- package/src/routes/pages/archive.tsx +4 -2
- package/src/services/__tests__/post.test.ts +19 -19
- package/src/services/__tests__/search.test.ts +0 -1
- package/src/services/__tests__/settings.test.ts +22 -3
- package/src/services/bootstrap.ts +7 -3
- package/src/services/collection.ts +3 -3
- package/src/services/export.ts +0 -3
- package/src/services/navigation.ts +0 -2
- package/src/services/path.ts +1 -38
- package/src/services/post.ts +32 -66
- package/src/services/search.ts +0 -6
- package/src/services/settings.ts +47 -6
- package/src/services/site-admin.ts +6 -1
- package/src/styles/ui.css +12 -23
- package/src/types/entities.ts +0 -1
- package/src/ui/color-themes.ts +1 -1
- package/src/ui/dash/settings/GeneralContent.tsx +17 -19
- package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
- package/src/ui/feed/NoteCard.tsx +1 -11
- package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
- package/src/ui/pages/PostPage.tsx +2 -0
- package/bin/commands/collections.js +0 -268
- package/bin/commands/media.js +0 -302
- package/bin/commands/posts.js +0 -262
- package/bin/commands/search.js +0 -53
- package/bin/commands/settings.js +0 -93
- package/bin/lib/http-api.js +0 -223
- package/bin/lib/media-upload.js +0 -206
- package/dist/app-CM7sb3xO.js +0 -5
- package/dist/client/_assets/client-DDs6NzB3.css +0 -2
- package/src/__tests__/bin/content-cli.test.ts +0 -179
- package/src/__tests__/bin/media-cli.test.ts +0 -192
- /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
- /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
|
@@ -47,6 +47,20 @@ function shouldBlockFraming(path: string): boolean {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Paths whose responses are not HTML rendered by `BaseLayout` and therefore
|
|
52
|
+
* never carry author-pasted `customHeadHtml` / `customBodyEndHtml`. Skipping
|
|
53
|
+
* the settings lookup for these avoids two DB roundtrips on every static
|
|
54
|
+
* asset request.
|
|
55
|
+
*/
|
|
56
|
+
function couldRenderCodeInjection(path: string): boolean {
|
|
57
|
+
if (shouldBlockFraming(path)) return false;
|
|
58
|
+
if (path === "/favicon.ico" || path === "/apple-touch-icon.png") return false;
|
|
59
|
+
if (path === "/healthz" || path === "/readyz") return false;
|
|
60
|
+
if (path.startsWith("/media/") || path.startsWith("/sites/")) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
function tryGetOrigin(value: string | undefined): string | null {
|
|
51
65
|
if (!value) return null;
|
|
52
66
|
try {
|
|
@@ -109,6 +123,7 @@ function toHonoCspOptions(
|
|
|
109
123
|
function buildSecureHeadersOptions(
|
|
110
124
|
path: string,
|
|
111
125
|
env: Bindings,
|
|
126
|
+
allowInlineScript: boolean,
|
|
112
127
|
): SecureHeadersOptions {
|
|
113
128
|
const directives = buildCspDirectives({
|
|
114
129
|
path,
|
|
@@ -116,6 +131,7 @@ function buildSecureHeadersOptions(
|
|
|
116
131
|
assetOrigin: tryGetOrigin(getEnvString(env, "ASSET_BASE_URL")),
|
|
117
132
|
uploadConnectSources: getDirectUploadConnectSources(env),
|
|
118
133
|
isDev: IS_VITE_DEV,
|
|
134
|
+
allowInlineScript,
|
|
119
135
|
});
|
|
120
136
|
|
|
121
137
|
return {
|
|
@@ -134,8 +150,38 @@ function buildSecureHeadersOptions(
|
|
|
134
150
|
};
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Probe the settings service for any author-saved code injection. Resolves to
|
|
155
|
+
* `false` whenever the lookup is unavailable (e.g. in unit tests that skip the
|
|
156
|
+
* runtime middleware) or the path can't render `BaseLayout`.
|
|
157
|
+
*
|
|
158
|
+
* Costs two settings reads on public HTML routes; static asset and
|
|
159
|
+
* frame-protected paths are short-circuited above.
|
|
160
|
+
*/
|
|
161
|
+
async function detectInlineScriptOptIn(
|
|
162
|
+
path: string,
|
|
163
|
+
settings: { get(key: string): Promise<string | null> } | undefined,
|
|
164
|
+
): Promise<boolean> {
|
|
165
|
+
if (!settings) return false;
|
|
166
|
+
if (!couldRenderCodeInjection(path)) return false;
|
|
167
|
+
const [head, bodyEnd] = await Promise.all([
|
|
168
|
+
settings.get("CUSTOM_HEAD_HTML"),
|
|
169
|
+
settings.get("CUSTOM_BODY_END_HTML"),
|
|
170
|
+
]);
|
|
171
|
+
return Boolean(head?.trim() || bodyEnd?.trim());
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
export function secureHeadersMiddleware(): MiddlewareHandler<Env> {
|
|
138
175
|
return async (c, next) => {
|
|
139
|
-
|
|
176
|
+
// `services` is set by the runtime bootstrap middleware. Cast through
|
|
177
|
+
// `undefined` so unit tests that skip that middleware still work.
|
|
178
|
+
const services = c.var.services as AppVariables["services"] | undefined;
|
|
179
|
+
const allowInlineScript = await detectInlineScriptOptIn(
|
|
180
|
+
c.req.path,
|
|
181
|
+
services?.settings,
|
|
182
|
+
);
|
|
183
|
+
return secureHeaders(
|
|
184
|
+
buildSecureHeadersOptions(c.req.path, c.env, allowInlineScript),
|
|
185
|
+
)(c, next);
|
|
140
186
|
};
|
|
141
187
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { loadNodeEnvFile } from "../../../bin/lib/node-env.js";
|
|
6
|
+
import {
|
|
7
|
+
formatRuntimeBanner,
|
|
8
|
+
resolveCliRuntime,
|
|
9
|
+
} from "../../../bin/lib/runtime-target.js";
|
|
3
10
|
|
|
4
11
|
describe("resolveCliRuntime", () => {
|
|
5
12
|
it("prefers the Node database runtime when DATABASE_URL is present", () => {
|
|
@@ -34,4 +41,105 @@ describe("resolveCliRuntime", () => {
|
|
|
34
41
|
/Choose only one of --local, --remote/,
|
|
35
42
|
);
|
|
36
43
|
});
|
|
44
|
+
|
|
45
|
+
it("forces the Node runtime when --node is passed even without DATABASE_URL", () => {
|
|
46
|
+
expect(resolveCliRuntime({ node: true }, {})).toBe("node");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("loadNodeEnvFile", () => {
|
|
51
|
+
let tmpDir: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
tmpDir = mkdtempSync(join(tmpdir(), "jant-env-"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("assigns missing keys but never overwrites existing ones", () => {
|
|
58
|
+
const envPath = join(tmpDir, ".env.node");
|
|
59
|
+
writeFileSync(
|
|
60
|
+
envPath,
|
|
61
|
+
[
|
|
62
|
+
"# comment",
|
|
63
|
+
"",
|
|
64
|
+
"DATABASE_URL=postgres://example/db",
|
|
65
|
+
"AUTH_SECRET=already-set-in-shell",
|
|
66
|
+
].join("\n"),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const env: Record<string, string> = {
|
|
70
|
+
AUTH_SECRET: "shell-wins",
|
|
71
|
+
};
|
|
72
|
+
const result = loadNodeEnvFile(envPath, env);
|
|
73
|
+
|
|
74
|
+
expect(result.found).toBe(true);
|
|
75
|
+
expect(result.assignedKeys).toEqual(["DATABASE_URL"]);
|
|
76
|
+
expect(result.skippedKeys).toEqual(["AUTH_SECRET"]);
|
|
77
|
+
expect(env.DATABASE_URL).toBe("postgres://example/db");
|
|
78
|
+
expect(env.AUTH_SECRET).toBe("shell-wins");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns found=false when the file does not exist", () => {
|
|
82
|
+
const result = loadNodeEnvFile(join(tmpDir, "missing"));
|
|
83
|
+
expect(result.found).toBe(false);
|
|
84
|
+
expect(result.assignedKeys).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("strips matching surrounding quotes from values", () => {
|
|
88
|
+
const envPath = join(tmpDir, ".env.node");
|
|
89
|
+
writeFileSync(
|
|
90
|
+
envPath,
|
|
91
|
+
[
|
|
92
|
+
'STORAGE_DRIVER="local"',
|
|
93
|
+
"SITE_NAME='Quoted Title'",
|
|
94
|
+
"PUBLIC_URL=https://example.com",
|
|
95
|
+
'MIXED="left only',
|
|
96
|
+
].join("\n"),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const env: Record<string, string> = {};
|
|
100
|
+
loadNodeEnvFile(envPath, env);
|
|
101
|
+
|
|
102
|
+
expect(env.STORAGE_DRIVER).toBe("local");
|
|
103
|
+
expect(env.SITE_NAME).toBe("Quoted Title");
|
|
104
|
+
expect(env.PUBLIC_URL).toBe("https://example.com");
|
|
105
|
+
expect(env.MIXED).toBe('"left only');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("formatRuntimeBanner", () => {
|
|
110
|
+
const originalLog = console.log;
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
console.log = vi.fn();
|
|
113
|
+
});
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
console.log = originalLog;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("describes a postgres node target", () => {
|
|
119
|
+
const banner = formatRuntimeBanner("node", {
|
|
120
|
+
DATABASE_URL: "postgresql://app@db.local:5432/jant_main",
|
|
121
|
+
});
|
|
122
|
+
expect(banner).toBe(
|
|
123
|
+
"[jant] target = node (postgresql db.local:5432/jant_main)",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("describes a sqlite node target", () => {
|
|
128
|
+
const banner = formatRuntimeBanner("node", {
|
|
129
|
+
DATABASE_URL: "file:./local.sqlite",
|
|
130
|
+
});
|
|
131
|
+
expect(banner).toBe("[jant] target = node (sqlite file:./local.sqlite)");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("describes wrangler local D1", () => {
|
|
135
|
+
expect(formatRuntimeBanner("d1-local", {})).toBe(
|
|
136
|
+
"[jant] target = local D1 (wrangler)",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("describes wrangler remote D1", () => {
|
|
141
|
+
expect(formatRuntimeBanner("d1-remote", {})).toBe(
|
|
142
|
+
"[jant] target = remote D1 (wrangler)",
|
|
143
|
+
);
|
|
144
|
+
});
|
|
37
145
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -18,6 +19,7 @@ const SNAPSHOT_POST_ID = "pst_01jpyy18fh4w2m7r8k3c5t9qdn";
|
|
|
18
19
|
const SNAPSHOT_PATH_ID = "pth_01jpyy1k2v6m4s8r1t5c9b3qgh";
|
|
19
20
|
const SNAPSHOT_MEDIA_ID = "med_01jpyy1vxh4m7s2k8r5c9t3qbn";
|
|
20
21
|
const SNAPSHOT_AVATAR_MEDIA_ID = "med_01jpyy1zs6m4v8r2k5t9c3b7qh";
|
|
22
|
+
const SNAPSHOT_APPLE_TOUCH_MEDIA_ID = "med_01jpyy20kt5n9r3k8t6c4d2qhf";
|
|
21
23
|
const SNAPSHOT_MEDIA_KEY = `media/${SNAPSHOT_SITE_ID}/files/${SNAPSHOT_MEDIA_ID}.png`;
|
|
22
24
|
const SNAPSHOT_POSTER_KEY = `media/${SNAPSHOT_SITE_ID}/posters/${SNAPSHOT_MEDIA_ID}.webp`;
|
|
23
25
|
const SNAPSHOT_AVATAR_KEY = `media/${SNAPSHOT_SITE_ID}/assets/avatar/${SNAPSHOT_AVATAR_MEDIA_ID}.png`;
|
|
@@ -154,6 +156,24 @@ describe("jant site snapshot export/import", () => {
|
|
|
154
156
|
'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
|
|
155
157
|
1774009200, 1774009200
|
|
156
158
|
);
|
|
159
|
+
|
|
160
|
+
INSERT INTO "media" (
|
|
161
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
162
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
163
|
+
) VALUES (
|
|
164
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
165
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
|
|
166
|
+
'local', 'a0', 'image', 1774009202, 1774009202
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
INSERT INTO "media" (
|
|
170
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
171
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
172
|
+
) VALUES (
|
|
173
|
+
'${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
174
|
+
'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
|
|
175
|
+
'local', 'a0', 'image', 1774009203, 1774009203
|
|
176
|
+
);
|
|
157
177
|
`);
|
|
158
178
|
|
|
159
179
|
targetSqlite.exec(`
|
|
@@ -207,15 +227,16 @@ describe("jant site snapshot export/import", () => {
|
|
|
207
227
|
await import("../../../bin/commands/site/snapshot/export.js");
|
|
208
228
|
await runExport(["--output", snapshotPath]);
|
|
209
229
|
|
|
210
|
-
const
|
|
211
|
-
await readFile(join(snapshotPath, "
|
|
230
|
+
const meta = JSON.parse(
|
|
231
|
+
await readFile(join(snapshotPath, "meta.json"), "utf-8"),
|
|
212
232
|
);
|
|
213
|
-
expect(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
233
|
+
expect(meta).toEqual({
|
|
234
|
+
format: "jant-site-snapshot",
|
|
235
|
+
version: 1,
|
|
236
|
+
dialect: "sqlite",
|
|
237
|
+
site: { id: SNAPSHOT_SITE_ID, key: SNAPSHOT_SITE_KEY },
|
|
238
|
+
});
|
|
239
|
+
expect(existsSync(join(snapshotPath, "storage-manifest.json"))).toBe(false);
|
|
219
240
|
expect(exportLogSpy).toHaveBeenCalledWith(
|
|
220
241
|
`Exported Node database snapshot to ${snapshotPath}`,
|
|
221
242
|
);
|
|
@@ -311,6 +332,249 @@ describe("jant site snapshot export/import", () => {
|
|
|
311
332
|
);
|
|
312
333
|
});
|
|
313
334
|
|
|
335
|
+
it("skips downloading storage objects when --skip-objects is passed", async () => {
|
|
336
|
+
const root = await mkdtemp(
|
|
337
|
+
join(tmpdir(), "jant-site-snapshot-skip-objects-"),
|
|
338
|
+
);
|
|
339
|
+
tempDirs.push(root);
|
|
340
|
+
|
|
341
|
+
const sourceDbPath = join(root, "source.sqlite");
|
|
342
|
+
const sourceStoragePath = join(root, "source-media");
|
|
343
|
+
const snapshotPath = join(root, "snapshot");
|
|
344
|
+
|
|
345
|
+
await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
|
|
346
|
+
const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
|
|
347
|
+
await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
|
|
348
|
+
contentType: "image/png",
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const sourceSqlite = new Database(sourceDbPath);
|
|
352
|
+
try {
|
|
353
|
+
sourceSqlite.exec(`
|
|
354
|
+
INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
|
|
355
|
+
VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
|
|
356
|
+
|
|
357
|
+
INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
|
|
358
|
+
('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Skip Objects Source', 1774009200);
|
|
359
|
+
|
|
360
|
+
INSERT INTO "post" (
|
|
361
|
+
"id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
|
|
362
|
+
"thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
|
|
363
|
+
) VALUES (
|
|
364
|
+
'${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
|
|
365
|
+
'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
|
|
366
|
+
'${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
INSERT INTO "media" (
|
|
370
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
371
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
372
|
+
) VALUES (
|
|
373
|
+
'${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
|
|
374
|
+
'${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
|
|
375
|
+
'local', 'a0', 'image', 1774009200, 1774009200
|
|
376
|
+
);
|
|
377
|
+
`);
|
|
378
|
+
} finally {
|
|
379
|
+
sourceSqlite.close();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
process.env.DATABASE_URL = `file:${sourceDbPath}`;
|
|
383
|
+
process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
|
|
384
|
+
|
|
385
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
386
|
+
const { run: runExport } =
|
|
387
|
+
await import("../../../bin/commands/site/snapshot/export.js");
|
|
388
|
+
await runExport(["--output", snapshotPath, "--skip-objects"]);
|
|
389
|
+
|
|
390
|
+
expect(existsSync(join(snapshotPath, "meta.json"))).toBe(true);
|
|
391
|
+
expect(existsSync(join(snapshotPath, "db.sql"))).toBe(true);
|
|
392
|
+
expect(existsSync(join(snapshotPath, "objects"))).toBe(false);
|
|
393
|
+
|
|
394
|
+
const dbSql = await readFile(join(snapshotPath, "db.sql"), "utf-8");
|
|
395
|
+
expect(dbSql).toContain(SNAPSHOT_MEDIA_KEY);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("aborts import when objects/ is missing keys referenced by db.sql", async () => {
|
|
399
|
+
const root = await mkdtemp(
|
|
400
|
+
join(tmpdir(), "jant-site-snapshot-missing-objects-"),
|
|
401
|
+
);
|
|
402
|
+
tempDirs.push(root);
|
|
403
|
+
|
|
404
|
+
const sourceDbPath = join(root, "source.sqlite");
|
|
405
|
+
const sourceStoragePath = join(root, "source-media");
|
|
406
|
+
const targetDbPath = join(root, "target.sqlite");
|
|
407
|
+
const targetStoragePath = join(root, "target-media");
|
|
408
|
+
const snapshotPath = join(root, "snapshot");
|
|
409
|
+
|
|
410
|
+
await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
|
|
411
|
+
await migrate({ DATABASE_URL: `file:${targetDbPath}` } as Bindings);
|
|
412
|
+
|
|
413
|
+
const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
|
|
414
|
+
await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
|
|
415
|
+
contentType: "image/png",
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const sourceSqlite = new Database(sourceDbPath);
|
|
419
|
+
const targetSqlite = new Database(targetDbPath);
|
|
420
|
+
try {
|
|
421
|
+
sourceSqlite.exec(`
|
|
422
|
+
INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
|
|
423
|
+
VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
|
|
424
|
+
|
|
425
|
+
INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
|
|
426
|
+
('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Source', 1774009200);
|
|
427
|
+
|
|
428
|
+
INSERT INTO "post" (
|
|
429
|
+
"id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
|
|
430
|
+
"thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
|
|
431
|
+
) VALUES (
|
|
432
|
+
'${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
|
|
433
|
+
'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
|
|
434
|
+
'${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
INSERT INTO "media" (
|
|
438
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
439
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
440
|
+
) VALUES (
|
|
441
|
+
'${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
|
|
442
|
+
'${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
|
|
443
|
+
'local', 'a0', 'image', 1774009200, 1774009200
|
|
444
|
+
);
|
|
445
|
+
`);
|
|
446
|
+
|
|
447
|
+
targetSqlite.exec(`
|
|
448
|
+
INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
|
|
449
|
+
VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009000, 1774009000);
|
|
450
|
+
`);
|
|
451
|
+
} finally {
|
|
452
|
+
sourceSqlite.close();
|
|
453
|
+
targetSqlite.close();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
process.env.DATABASE_URL = `file:${sourceDbPath}`;
|
|
457
|
+
process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
|
|
458
|
+
|
|
459
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
460
|
+
const { run: runExport } =
|
|
461
|
+
await import("../../../bin/commands/site/snapshot/export.js");
|
|
462
|
+
await runExport(["--output", snapshotPath, "--skip-objects"]);
|
|
463
|
+
|
|
464
|
+
process.env.DATABASE_URL = `file:${targetDbPath}`;
|
|
465
|
+
process.env.LOCAL_STORAGE_PATH = targetStoragePath;
|
|
466
|
+
|
|
467
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
468
|
+
const { run: runImport } =
|
|
469
|
+
await import("../../../bin/commands/site/snapshot/import.js");
|
|
470
|
+
|
|
471
|
+
await expect(
|
|
472
|
+
runImport(["--path", snapshotPath, "--replace"]),
|
|
473
|
+
).rejects.toThrow(/missing storage objects/);
|
|
474
|
+
|
|
475
|
+
expect(warnSpy.mock.calls.flat().join("\n")).toContain(SNAPSHOT_MEDIA_KEY);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("imports a --skip-objects snapshot when --allow-missing-objects is passed", async () => {
|
|
479
|
+
const root = await mkdtemp(
|
|
480
|
+
join(tmpdir(), "jant-site-snapshot-allow-missing-"),
|
|
481
|
+
);
|
|
482
|
+
tempDirs.push(root);
|
|
483
|
+
|
|
484
|
+
const sourceDbPath = join(root, "source.sqlite");
|
|
485
|
+
const sourceStoragePath = join(root, "source-media");
|
|
486
|
+
const targetDbPath = join(root, "target.sqlite");
|
|
487
|
+
const targetStoragePath = join(root, "target-media");
|
|
488
|
+
const snapshotPath = join(root, "snapshot");
|
|
489
|
+
|
|
490
|
+
await migrate({ DATABASE_URL: `file:${sourceDbPath}` } as Bindings);
|
|
491
|
+
await migrate({ DATABASE_URL: `file:${targetDbPath}` } as Bindings);
|
|
492
|
+
|
|
493
|
+
const sourceStorage = createLocalDriver({ rootPath: sourceStoragePath });
|
|
494
|
+
const targetStorage = createLocalDriver({ rootPath: targetStoragePath });
|
|
495
|
+
await sourceStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
|
|
496
|
+
contentType: "image/png",
|
|
497
|
+
});
|
|
498
|
+
// Simulate a shared bucket: target already has the file.
|
|
499
|
+
await targetStorage.put(SNAPSHOT_MEDIA_KEY, new Uint8Array([1, 2, 3, 4]), {
|
|
500
|
+
contentType: "image/png",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const sourceSqlite = new Database(sourceDbPath);
|
|
504
|
+
const targetSqlite = new Database(targetDbPath);
|
|
505
|
+
try {
|
|
506
|
+
sourceSqlite.exec(`
|
|
507
|
+
INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
|
|
508
|
+
VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009100, 1774009100);
|
|
509
|
+
|
|
510
|
+
INSERT INTO "site_setting" ("site_id", "key", "value", "updated_at") VALUES
|
|
511
|
+
('${SNAPSHOT_SITE_ID}', 'SITE_NAME', 'Source', 1774009200);
|
|
512
|
+
|
|
513
|
+
INSERT INTO "post" (
|
|
514
|
+
"id", "site_id", "format", "status", "visibility", "title", "body", "body_html", "body_text",
|
|
515
|
+
"thread_id", "published_at", "last_activity_at", "created_at", "updated_at"
|
|
516
|
+
) VALUES (
|
|
517
|
+
'${SNAPSHOT_POST_ID}', '${SNAPSHOT_SITE_ID}', 'note', 'published', 'public',
|
|
518
|
+
'Snapshot post', 'Hello snapshot', '<p>Hello snapshot</p>', 'Hello snapshot',
|
|
519
|
+
'${SNAPSHOT_POST_ID}', 1774009200, 1774009200, 1774009200, 1774009200
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
INSERT INTO "media" (
|
|
523
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
524
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
525
|
+
) VALUES (
|
|
526
|
+
'${SNAPSHOT_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', '${SNAPSHOT_POST_ID}',
|
|
527
|
+
'${SNAPSHOT_MEDIA_ID}.png', 'sample.png', 'image/png', 4, '${SNAPSHOT_MEDIA_KEY}',
|
|
528
|
+
'local', 'a0', 'image', 1774009200, 1774009200
|
|
529
|
+
);
|
|
530
|
+
`);
|
|
531
|
+
|
|
532
|
+
targetSqlite.exec(`
|
|
533
|
+
INSERT INTO "site" ("id", "key", "status", "created_at", "updated_at")
|
|
534
|
+
VALUES ('${SNAPSHOT_SITE_ID}', '${SNAPSHOT_SITE_KEY}', 'active', 1774009000, 1774009000);
|
|
535
|
+
`);
|
|
536
|
+
} finally {
|
|
537
|
+
sourceSqlite.close();
|
|
538
|
+
targetSqlite.close();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
process.env.DATABASE_URL = `file:${sourceDbPath}`;
|
|
542
|
+
process.env.LOCAL_STORAGE_PATH = sourceStoragePath;
|
|
543
|
+
|
|
544
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
545
|
+
const { run: runExport } =
|
|
546
|
+
await import("../../../bin/commands/site/snapshot/export.js");
|
|
547
|
+
await runExport(["--output", snapshotPath, "--skip-objects"]);
|
|
548
|
+
|
|
549
|
+
process.env.DATABASE_URL = `file:${targetDbPath}`;
|
|
550
|
+
process.env.LOCAL_STORAGE_PATH = targetStoragePath;
|
|
551
|
+
|
|
552
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
553
|
+
const { run: runImport } =
|
|
554
|
+
await import("../../../bin/commands/site/snapshot/import.js");
|
|
555
|
+
await runImport([
|
|
556
|
+
"--path",
|
|
557
|
+
snapshotPath,
|
|
558
|
+
"--replace",
|
|
559
|
+
"--allow-missing-objects",
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
const verifySqlite = new Database(targetDbPath, { readonly: true });
|
|
563
|
+
try {
|
|
564
|
+
const mediaRow = verifySqlite
|
|
565
|
+
.prepare(
|
|
566
|
+
`SELECT "storage_key" FROM "media" WHERE "id" = '${SNAPSHOT_MEDIA_ID}'`,
|
|
567
|
+
)
|
|
568
|
+
.get() as { storage_key: string } | undefined;
|
|
569
|
+
expect(mediaRow?.storage_key).toBe(SNAPSHOT_MEDIA_KEY);
|
|
570
|
+
} finally {
|
|
571
|
+
verifySqlite.close();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Target storage still has the pre-existing file (we didn't try to upload).
|
|
575
|
+
expect(await targetStorage.get(SNAPSHOT_MEDIA_KEY)).not.toBeNull();
|
|
576
|
+
});
|
|
577
|
+
|
|
314
578
|
it("requires --replace for snapshot import", async () => {
|
|
315
579
|
const root = await mkdtemp(join(tmpdir(), "jant-site-snapshot-replace-"));
|
|
316
580
|
tempDirs.push(root);
|
|
@@ -328,16 +592,11 @@ describe("jant site snapshot export/import", () => {
|
|
|
328
592
|
{
|
|
329
593
|
format: "jant-site-snapshot",
|
|
330
594
|
version: 1,
|
|
331
|
-
scope: "content",
|
|
332
595
|
},
|
|
333
596
|
null,
|
|
334
597
|
2,
|
|
335
598
|
),
|
|
336
599
|
),
|
|
337
|
-
writeFile(
|
|
338
|
-
join(snapshotPath, "storage-manifest.json"),
|
|
339
|
-
JSON.stringify({ version: 1, objects: [] }, null, 2),
|
|
340
|
-
),
|
|
341
600
|
writeFile(join(snapshotPath, "db.sql"), ""),
|
|
342
601
|
]);
|
|
343
602
|
|
|
@@ -414,6 +673,24 @@ describe("jant site snapshot export/import", () => {
|
|
|
414
673
|
'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
|
|
415
674
|
1774009200, 1774009200
|
|
416
675
|
);
|
|
676
|
+
|
|
677
|
+
INSERT INTO "media" (
|
|
678
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
679
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
680
|
+
) VALUES (
|
|
681
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
682
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
|
|
683
|
+
'local', 'a0', 'image', 1774009202, 1774009202
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
INSERT INTO "media" (
|
|
687
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
688
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
689
|
+
) VALUES (
|
|
690
|
+
'${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
691
|
+
'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
|
|
692
|
+
'local', 'a0', 'image', 1774009203, 1774009203
|
|
693
|
+
);
|
|
417
694
|
`);
|
|
418
695
|
|
|
419
696
|
targetSqlite.exec(`
|
|
@@ -562,6 +839,24 @@ describe("jant site snapshot export/import", () => {
|
|
|
562
839
|
'local', 1, 1, 'Sample alt', 'a0', '${SNAPSHOT_POSTER_KEY}', 'image',
|
|
563
840
|
1774009200, 1774009200
|
|
564
841
|
);
|
|
842
|
+
|
|
843
|
+
INSERT INTO "media" (
|
|
844
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
845
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
846
|
+
) VALUES (
|
|
847
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
848
|
+
'${SNAPSHOT_AVATAR_MEDIA_ID}.png', 'avatar.png', 'image/png', 3, '${SNAPSHOT_AVATAR_KEY}',
|
|
849
|
+
'local', 'a0', 'image', 1774009202, 1774009202
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
INSERT INTO "media" (
|
|
853
|
+
"id", "site_id", "post_id", "filename", "original_name", "mime_type", "size", "storage_key",
|
|
854
|
+
"provider", "position", "media_kind", "created_at", "updated_at"
|
|
855
|
+
) VALUES (
|
|
856
|
+
'${SNAPSHOT_APPLE_TOUCH_MEDIA_ID}', '${SNAPSHOT_SITE_ID}', NULL,
|
|
857
|
+
'apple-touch-icon.png', 'apple-touch-icon.png', 'image/png', 3, '${SNAPSHOT_APPLE_TOUCH_KEY}',
|
|
858
|
+
'local', 'a0', 'image', 1774009203, 1774009203
|
|
859
|
+
);
|
|
565
860
|
`);
|
|
566
861
|
|
|
567
862
|
targetSqlite.exec(`
|
|
@@ -65,7 +65,6 @@ describe("site CLI token env", () => {
|
|
|
65
65
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
66
66
|
|
|
67
67
|
await runSiteExport([
|
|
68
|
-
"--url",
|
|
69
68
|
"https://example.com",
|
|
70
69
|
"--output",
|
|
71
70
|
outputPath,
|
|
@@ -95,7 +94,6 @@ describe("site CLI token env", () => {
|
|
|
95
94
|
|
|
96
95
|
await expect(
|
|
97
96
|
runImportSite([
|
|
98
|
-
"--url",
|
|
99
97
|
"https://example.com",
|
|
100
98
|
"--path",
|
|
101
99
|
"/definitely-missing-jant-import-source",
|
|
@@ -106,9 +104,7 @@ describe("site CLI token env", () => {
|
|
|
106
104
|
"Path not found: /definitely-missing-jant-import-source",
|
|
107
105
|
);
|
|
108
106
|
expect(errorSpy).not.toHaveBeenCalledWith(
|
|
109
|
-
expect.stringContaining(
|
|
110
|
-
"remote import requires JANT_API_TOKEN or --token",
|
|
111
|
-
),
|
|
107
|
+
expect.stringContaining("site import requires JANT_API_TOKEN or --token"),
|
|
112
108
|
);
|
|
113
109
|
});
|
|
114
110
|
|
|
@@ -123,7 +119,6 @@ describe("site CLI token env", () => {
|
|
|
123
119
|
|
|
124
120
|
await expect(
|
|
125
121
|
runImportSite([
|
|
126
|
-
"--url",
|
|
127
122
|
"https://example.com",
|
|
128
123
|
"--path",
|
|
129
124
|
"/definitely-missing-jant-import-source",
|
|
@@ -131,7 +126,7 @@ describe("site CLI token env", () => {
|
|
|
131
126
|
).rejects.toThrow("process.exit:1");
|
|
132
127
|
|
|
133
128
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
134
|
-
"Error:
|
|
129
|
+
"Error: site import requires JANT_API_TOKEN or --token (unless using --dry-run)",
|
|
135
130
|
);
|
|
136
131
|
});
|
|
137
132
|
});
|