artifacty 0.10.8 → 0.10.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/README.md +10 -0
- package/docs/central-team-deployment-design.md +3 -1
- package/docs/integrations.md +9 -1
- package/package.json +1 -1
- package/src/lib/backup.js +139 -12
- package/src/lib/render.js +76 -2
- package/src/server.js +104 -6
package/README.md
CHANGED
|
@@ -258,6 +258,13 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
|
|
|
258
258
|
|
|
259
259
|
Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as append-only version files under `artifacts/` for normal create and update flows. Administrators can repair or delete individual bad versions from the browser, and those exceptional actions are recorded in the audit log. The current browser server URL is written to `server.json` so MCP tools can return the correct links when the default port falls back. Existing `index.json` stores are migrated automatically on first access.
|
|
260
260
|
|
|
261
|
+
Administrators can download and restore artifact backups from `/admin/backup`.
|
|
262
|
+
The backup bundle contains artifact metadata and version contents, but not
|
|
263
|
+
users, sessions, API token records, or audit logs. Restoring a bundle replaces
|
|
264
|
+
the target server's artifact records and prunes unreferenced version files. For
|
|
265
|
+
large migrations or scripted server moves, use `artifacty backup` and
|
|
266
|
+
`artifacty import-store --file ./artifacty-backup.json`.
|
|
267
|
+
|
|
261
268
|
Search uses a SQLite FTS5 index when the local Node SQLite build supports it. The index covers the latest version body plus title, tags, source agent, artifact type, format, and metadata summary. If FTS5 is unavailable, Artifacty keeps working with metadata search. Rebuild or check the store when needed:
|
|
262
269
|
|
|
263
270
|
```bash
|
|
@@ -309,6 +316,9 @@ Browser routes:
|
|
|
309
316
|
- `/artifacts/:id/edit`: save a new version with Markdown, HTML, JSON, text, code, SVG, Mermaid, React, SARIF, CSV, image, or video syntax support. Browser edits that do not change the artifact are recorded as `update-noop` audit events without creating a version.
|
|
310
317
|
- `/artifacts/:id/diff`: compare versions.
|
|
311
318
|
- `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
|
|
319
|
+
- `/admin/backup`: administrator-only artifact backup download and restore screen.
|
|
320
|
+
- `/api/admin/backup`: administrator-only artifact backup JSON download.
|
|
321
|
+
- `/api/admin/backup/import`: administrator-only artifact backup restore.
|
|
312
322
|
- `/api/audit`: list audit events.
|
|
313
323
|
|
|
314
324
|
List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
|
|
@@ -132,7 +132,9 @@ the user's email as `actor` and created artifacts record the same email as
|
|
|
132
132
|
Production-like internal deployments should run Artifacty behind a TLS reverse
|
|
133
133
|
proxy, keep `ARTIFACTY_ENABLE_REACT_RENDERER` disabled unless the team trusts
|
|
134
134
|
all artifact authors, and store `ARTIFACTY_HOME` on local server disk with
|
|
135
|
-
regular backups.
|
|
135
|
+
regular backups. Administrators can export and restore artifact backup bundles
|
|
136
|
+
from `/admin/backup`; large migrations can use `artifacty backup` on the old
|
|
137
|
+
server and `artifacty import-store --file <backup.json>` on the new server.
|
|
136
138
|
|
|
137
139
|
For Linux user-service deployments, enable systemd lingering for the service
|
|
138
140
|
account:
|
package/docs/integrations.md
CHANGED
|
@@ -402,6 +402,10 @@ For background services, prefer a stable `ARTIFACTY_API_TOKEN` or `--api-token`
|
|
|
402
402
|
|
|
403
403
|
## Backup and Audit
|
|
404
404
|
|
|
405
|
+
Administrators can open `/admin/backup` to download an artifact backup JSON file
|
|
406
|
+
or restore a backup into the current server. The browser restore flow is useful
|
|
407
|
+
for small and medium migrations; use the CLI for large bundles.
|
|
408
|
+
|
|
405
409
|
```bash
|
|
406
410
|
node src/cli.js audit --limit 20
|
|
407
411
|
node src/cli.js backup
|
|
@@ -409,4 +413,8 @@ node src/cli.js export --file ./artifacty-backup.json
|
|
|
409
413
|
node src/cli.js import-store --file ./artifacty-backup.json
|
|
410
414
|
```
|
|
411
415
|
|
|
412
|
-
Backups include
|
|
416
|
+
Backups include artifact metadata plus immutable version file contents in one
|
|
417
|
+
JSON bundle. They do not include users, sessions, API token records, or audit
|
|
418
|
+
logs. Importing a store replaces the target artifact records and prunes
|
|
419
|
+
unreferenced version files, so run it against a new or intentionally chosen
|
|
420
|
+
`ARTIFACTY_HOME`.
|
package/package.json
CHANGED
package/src/lib/backup.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createStore, loadIndex, writeIndex } from "./storage.js";
|
|
4
4
|
|
|
5
|
+
export const MAX_BACKUP_BYTES = 128 * 1024 * 1024;
|
|
6
|
+
|
|
5
7
|
export async function exportStore(store = createStore(), outputPath) {
|
|
6
8
|
if (!outputPath) {
|
|
7
9
|
throw new Error("export requires --file <path>");
|
|
8
10
|
}
|
|
11
|
+
const bundle = await buildStoreBackup(store);
|
|
12
|
+
await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
13
|
+
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
14
|
+
return {
|
|
15
|
+
path: path.resolve(outputPath),
|
|
16
|
+
artifactCount: bundle.artifacts.length,
|
|
17
|
+
exportedAt: bundle.exportedAt
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function exportStoreToString(store = createStore()) {
|
|
22
|
+
const bundle = await buildStoreBackup(store);
|
|
23
|
+
return `${JSON.stringify(bundle, null, 2)}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function buildStoreBackup(store = createStore()) {
|
|
9
27
|
const index = await loadIndex(store);
|
|
10
28
|
const artifacts = [];
|
|
11
29
|
for (const artifact of index.artifacts) {
|
|
@@ -13,24 +31,18 @@ export async function exportStore(store = createStore(), outputPath) {
|
|
|
13
31
|
for (const version of artifact.versions) {
|
|
14
32
|
versions.push({
|
|
15
33
|
...version,
|
|
34
|
+
path: normalizeBackupRelativePath(version.path),
|
|
16
35
|
content: await readFile(path.join(store.home, version.path), "utf8")
|
|
17
36
|
});
|
|
18
37
|
}
|
|
19
38
|
artifacts.push({ ...artifact, versions });
|
|
20
39
|
}
|
|
21
40
|
|
|
22
|
-
|
|
41
|
+
return {
|
|
23
42
|
schemaVersion: 1,
|
|
24
43
|
exportedAt: new Date().toISOString(),
|
|
25
44
|
artifacts
|
|
26
45
|
};
|
|
27
|
-
await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
28
|
-
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
29
|
-
return {
|
|
30
|
-
path: path.resolve(outputPath),
|
|
31
|
-
artifactCount: artifacts.length,
|
|
32
|
-
exportedAt: bundle.exportedAt
|
|
33
|
-
};
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
export async function importStore(store = createStore(), inputPath) {
|
|
@@ -38,8 +50,41 @@ export async function importStore(store = createStore(), inputPath) {
|
|
|
38
50
|
throw new Error("import-store requires --file <path>");
|
|
39
51
|
}
|
|
40
52
|
const bundle = JSON.parse(await readFile(inputPath, "utf8"));
|
|
53
|
+
const result = await importStoreBundle(store, bundle);
|
|
54
|
+
return {
|
|
55
|
+
path: path.resolve(inputPath),
|
|
56
|
+
...result
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function importStoreFromString(store = createStore(), content) {
|
|
61
|
+
if (!String(content || "").trim()) {
|
|
62
|
+
throw new Error("Artifacty backup JSON is required");
|
|
63
|
+
}
|
|
64
|
+
let bundle;
|
|
65
|
+
try {
|
|
66
|
+
bundle = JSON.parse(content);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw Object.assign(new Error(`Invalid Artifacty backup JSON: ${error.message}`), {
|
|
69
|
+
statusCode: 400,
|
|
70
|
+
code: "INVALID_BACKUP_JSON"
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return importStoreBundle(store, bundle);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function importStoreBundle(store = createStore(), bundle) {
|
|
77
|
+
if (!bundle || typeof bundle !== "object") {
|
|
78
|
+
throw Object.assign(new Error("Invalid Artifacty backup: JSON object expected"), {
|
|
79
|
+
statusCode: 400,
|
|
80
|
+
code: "INVALID_BACKUP"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
41
83
|
if (!Array.isArray(bundle.artifacts)) {
|
|
42
|
-
throw new Error("Invalid Artifacty backup: artifacts array missing")
|
|
84
|
+
throw Object.assign(new Error("Invalid Artifacty backup: artifacts array missing"), {
|
|
85
|
+
statusCode: 400,
|
|
86
|
+
code: "INVALID_BACKUP"
|
|
87
|
+
});
|
|
43
88
|
}
|
|
44
89
|
|
|
45
90
|
const index = {
|
|
@@ -52,8 +97,9 @@ export async function importStore(store = createStore(), inputPath) {
|
|
|
52
97
|
for (const version of artifact.versions || []) {
|
|
53
98
|
const cleanVersion = { ...version };
|
|
54
99
|
delete cleanVersion.content;
|
|
100
|
+
cleanVersion.path = normalizeBackupRelativePath(cleanVersion.path);
|
|
55
101
|
const content = version.content || "";
|
|
56
|
-
const absolutePath =
|
|
102
|
+
const absolutePath = backupVersionPath(store, cleanVersion.path);
|
|
57
103
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
58
104
|
await writeFile(absolutePath, content, "utf8");
|
|
59
105
|
versions.push(cleanVersion);
|
|
@@ -62,8 +108,8 @@ export async function importStore(store = createStore(), inputPath) {
|
|
|
62
108
|
}
|
|
63
109
|
|
|
64
110
|
await writeIndex(store, index);
|
|
111
|
+
await pruneUnreferencedArtifactFiles(store, index);
|
|
65
112
|
return {
|
|
66
|
-
path: path.resolve(inputPath),
|
|
67
113
|
artifactCount: index.artifacts.length,
|
|
68
114
|
importedAt: new Date().toISOString()
|
|
69
115
|
};
|
|
@@ -73,3 +119,84 @@ export function defaultBackupPath(store = createStore()) {
|
|
|
73
119
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
74
120
|
return path.join(store.home, "backups", `artifacty-${stamp}.json`);
|
|
75
121
|
}
|
|
122
|
+
|
|
123
|
+
function backupVersionPath(store, relativePath) {
|
|
124
|
+
const normalized = normalizeBackupRelativePath(relativePath);
|
|
125
|
+
const root = path.resolve(store.home);
|
|
126
|
+
const absolute = path.resolve(root, ...normalized.split("/"));
|
|
127
|
+
if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
|
|
128
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${relativePath}`), {
|
|
129
|
+
statusCode: 400,
|
|
130
|
+
code: "INVALID_BACKUP_PATH"
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return absolute;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeBackupRelativePath(relativePath) {
|
|
137
|
+
const value = String(relativePath || "");
|
|
138
|
+
if (!value || path.isAbsolute(value)) {
|
|
139
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
140
|
+
statusCode: 400,
|
|
141
|
+
code: "INVALID_BACKUP_PATH"
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const portable = value.replaceAll("\\", "/");
|
|
146
|
+
if (/^[A-Za-z]:\//.test(portable) || portable.startsWith("//")) {
|
|
147
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
148
|
+
statusCode: 400,
|
|
149
|
+
code: "INVALID_BACKUP_PATH"
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parts = portable
|
|
154
|
+
.split("/")
|
|
155
|
+
.filter((part) => part && part !== ".");
|
|
156
|
+
if (parts.length === 0 || parts.some((part) => part === "..")) {
|
|
157
|
+
throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
|
|
158
|
+
statusCode: 400,
|
|
159
|
+
code: "INVALID_BACKUP_PATH"
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return parts.join("/");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function pruneUnreferencedArtifactFiles(store, index) {
|
|
166
|
+
const referenced = new Set();
|
|
167
|
+
for (const artifact of index.artifacts) {
|
|
168
|
+
for (const version of artifact.versions || []) {
|
|
169
|
+
referenced.add(backupVersionPath(store, version.path));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const files = await listFiles(store.artifactsDir);
|
|
174
|
+
for (const filePath of files) {
|
|
175
|
+
if (!referenced.has(filePath)) {
|
|
176
|
+
await rm(filePath, { force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function listFiles(root) {
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code === "ENOENT") {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const files = [];
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const fullPath = path.join(root, entry.name);
|
|
195
|
+
if (entry.isDirectory()) {
|
|
196
|
+
files.push(...await listFiles(fullPath));
|
|
197
|
+
} else if (entry.isFile()) {
|
|
198
|
+
files.push(path.resolve(fullPath));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return files;
|
|
202
|
+
}
|
package/src/lib/render.js
CHANGED
|
@@ -399,7 +399,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
|
|
|
399
399
|
</div>
|
|
400
400
|
<nav>
|
|
401
401
|
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
402
|
-
${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
|
|
402
|
+
${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}
|
|
403
403
|
<a href="/account/password">Password</a>
|
|
404
404
|
<form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
|
|
405
405
|
${languageSwitcher(view)}
|
|
@@ -483,6 +483,61 @@ export function renderPasswordPage({ baseUrl, user, required = false, error = ""
|
|
|
483
483
|
});
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
export function renderAdminBackupPage({ baseUrl, user, integrity, importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/backup" }) {
|
|
487
|
+
const view = viewContext(locale, currentPath);
|
|
488
|
+
return pageShell({
|
|
489
|
+
title: "Backup",
|
|
490
|
+
body: `
|
|
491
|
+
<header class="topbar">
|
|
492
|
+
<div>
|
|
493
|
+
<h1>Backup</h1>
|
|
494
|
+
<p>${escapeHtml(baseUrl)}</p>
|
|
495
|
+
</div>
|
|
496
|
+
<nav>
|
|
497
|
+
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
498
|
+
<a href="/admin/users">Users</a>
|
|
499
|
+
<a href="/account">Account</a>
|
|
500
|
+
${languageSwitcher(view)}
|
|
501
|
+
</nav>
|
|
502
|
+
</header>
|
|
503
|
+
<main class="artifact-view">
|
|
504
|
+
${importResult ? `<p class="auth-success">Restore complete. ${escapeHtml(importResult.artifactCount)} artifacts imported.</p>` : ""}
|
|
505
|
+
${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
|
|
506
|
+
<section class="meta-card">
|
|
507
|
+
<h2>Store status</h2>
|
|
508
|
+
<table class="data-table">
|
|
509
|
+
<tbody>
|
|
510
|
+
<tr><th>Home</th><td>${escapeHtml(integrity.store)}</td></tr>
|
|
511
|
+
<tr><th>Artifacts</th><td>${escapeHtml(integrity.artifactCount)}</td></tr>
|
|
512
|
+
<tr><th>Versions</th><td>${escapeHtml(integrity.versionCount)}</td></tr>
|
|
513
|
+
<tr><th>Content bytes</th><td>${escapeHtml(integrity.totalBytes)}</td></tr>
|
|
514
|
+
<tr><th>Integrity</th><td>${integrity.ok ? "OK" : "Needs attention"}</td></tr>
|
|
515
|
+
</tbody>
|
|
516
|
+
</table>
|
|
517
|
+
${integrity.ok ? "" : `<p class="auth-warning">Run <code>artifacty integrity</code> before migration. Current backup will only include readable referenced version files.</p>`}
|
|
518
|
+
</section>
|
|
519
|
+
<section class="meta-card">
|
|
520
|
+
<h2>Download artifact backup</h2>
|
|
521
|
+
<p class="muted">Exports artifact metadata and version contents as one JSON bundle. Users, sessions, and API token records are not included.</p>
|
|
522
|
+
<p><a class="button-link" href="/admin/backup/export">Download backup JSON</a></p>
|
|
523
|
+
</section>
|
|
524
|
+
<section class="meta-card">
|
|
525
|
+
<h2>Restore artifact backup</h2>
|
|
526
|
+
<p class="auth-warning">Restore replaces the target server's artifact records and version files with the backup contents. Existing users, sessions, API tokens, and audit logs stay unchanged.</p>
|
|
527
|
+
<form class="editor-form" method="post" action="/admin/backup/import">
|
|
528
|
+
<label class="field content-field">
|
|
529
|
+
<span>Backup JSON</span>
|
|
530
|
+
<textarea class="compact-textarea" name="backup" spellcheck="false" placeholder="{"schemaVersion":1,"artifacts":[...]}" required></textarea>
|
|
531
|
+
</label>
|
|
532
|
+
<footer class="editor-actions"><button type="submit">Restore backup</button></footer>
|
|
533
|
+
</form>
|
|
534
|
+
</section>
|
|
535
|
+
</main>
|
|
536
|
+
`,
|
|
537
|
+
locale: view.locale
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
486
541
|
export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
|
|
487
542
|
const view = viewContext(locale, currentPath);
|
|
488
543
|
const rows = users.map((item) => `
|
|
@@ -542,6 +597,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
|
|
|
542
597
|
</div>
|
|
543
598
|
<nav>
|
|
544
599
|
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
600
|
+
<a href="/admin/backup">Backup</a>
|
|
545
601
|
<a href="/account">Account</a>
|
|
546
602
|
${languageSwitcher(view)}
|
|
547
603
|
</nav>
|
|
@@ -1361,6 +1417,24 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
1361
1417
|
transition: background 0.15s ease, border-color 0.15s ease;
|
|
1362
1418
|
}
|
|
1363
1419
|
button:hover { background: var(--accent-2); border-color: var(--accent-2); }
|
|
1420
|
+
.button-link {
|
|
1421
|
+
display: inline-flex;
|
|
1422
|
+
min-height: 38px;
|
|
1423
|
+
align-items: center;
|
|
1424
|
+
justify-content: center;
|
|
1425
|
+
padding: 7px 15px;
|
|
1426
|
+
border: 1px solid var(--accent);
|
|
1427
|
+
border-radius: 8px;
|
|
1428
|
+
background: var(--accent);
|
|
1429
|
+
color: var(--accent-ink);
|
|
1430
|
+
font-weight: 600;
|
|
1431
|
+
}
|
|
1432
|
+
.button-link:hover {
|
|
1433
|
+
border-color: var(--accent-2);
|
|
1434
|
+
background: var(--accent-2);
|
|
1435
|
+
color: var(--accent-ink);
|
|
1436
|
+
text-decoration: none;
|
|
1437
|
+
}
|
|
1364
1438
|
.inline-action button {
|
|
1365
1439
|
min-height: 32px;
|
|
1366
1440
|
padding: 5px 12px;
|
|
@@ -2043,7 +2117,7 @@ function authNav(user) {
|
|
|
2043
2117
|
if (!user) {
|
|
2044
2118
|
return `<a href="/login">Sign in</a>`;
|
|
2045
2119
|
}
|
|
2046
|
-
return `${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}<a href="/account">Account</a>`;
|
|
2120
|
+
return `${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}<a href="/account">Account</a>`;
|
|
2047
2121
|
}
|
|
2048
2122
|
|
|
2049
2123
|
export function escapeHtml(value) {
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
archiveArtifact,
|
|
9
9
|
authenticateApiToken,
|
|
10
10
|
changeUserPassword,
|
|
11
|
+
checkStoreIntegrity,
|
|
11
12
|
countUsers,
|
|
12
13
|
createApiToken,
|
|
13
14
|
createArtifact,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
updateArtifact,
|
|
32
33
|
verifyUserPassword
|
|
33
34
|
} from "./lib/storage.js";
|
|
35
|
+
import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
|
|
34
36
|
import { convertAgentArtifact } from "./lib/converters.js";
|
|
35
37
|
import { createLineDiff } from "./lib/diff.js";
|
|
36
38
|
import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
renderArtifactFormPage,
|
|
44
46
|
renderArtifactPage,
|
|
45
47
|
renderAccountPage,
|
|
48
|
+
renderAdminBackupPage,
|
|
46
49
|
renderAdminArtifactVersionsPage,
|
|
47
50
|
renderAdminUsersPage,
|
|
48
51
|
renderPasswordPage,
|
|
@@ -334,6 +337,60 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
334
337
|
return sendRedirect(response, "/account");
|
|
335
338
|
}
|
|
336
339
|
|
|
340
|
+
if (method === "GET" && pathname === "/admin/backup") {
|
|
341
|
+
if (!currentUser) {
|
|
342
|
+
return sendRedirect(response, "/login");
|
|
343
|
+
}
|
|
344
|
+
requireAdmin(currentUser);
|
|
345
|
+
return sendHtml(response, renderAdminBackupPage({
|
|
346
|
+
baseUrl,
|
|
347
|
+
user: currentUser,
|
|
348
|
+
integrity: await checkStoreIntegrity(store),
|
|
349
|
+
locale,
|
|
350
|
+
currentPath
|
|
351
|
+
}), 200, headOnly);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (method === "GET" && pathname === "/admin/backup/export") {
|
|
355
|
+
if (!currentUser) {
|
|
356
|
+
return sendRedirect(response, "/login");
|
|
357
|
+
}
|
|
358
|
+
requireAdmin(currentUser);
|
|
359
|
+
return sendBackupDownload(response, await exportStoreToString(store), headOnly);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (method === "POST" && pathname === "/admin/backup/import") {
|
|
363
|
+
assertLocalOrigin(request);
|
|
364
|
+
if (!currentUser) {
|
|
365
|
+
return sendRedirect(response, "/login");
|
|
366
|
+
}
|
|
367
|
+
requireAdmin(currentUser);
|
|
368
|
+
const body = await readFormBody(request, MAX_BACKUP_BYTES);
|
|
369
|
+
try {
|
|
370
|
+
const importResult = await importStoreFromString(store, body.backup || "");
|
|
371
|
+
return sendHtml(response, renderAdminBackupPage({
|
|
372
|
+
baseUrl,
|
|
373
|
+
user: currentUser,
|
|
374
|
+
integrity: await checkStoreIntegrity(store),
|
|
375
|
+
importResult,
|
|
376
|
+
locale,
|
|
377
|
+
currentPath: "/admin/backup"
|
|
378
|
+
}));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if ((error.statusCode || 500) >= 500) {
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
return sendHtml(response, renderAdminBackupPage({
|
|
384
|
+
baseUrl,
|
|
385
|
+
user: currentUser,
|
|
386
|
+
integrity: await checkStoreIntegrity(store),
|
|
387
|
+
importError: error.message,
|
|
388
|
+
locale,
|
|
389
|
+
currentPath: "/admin/backup"
|
|
390
|
+
}), error.statusCode || 400);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
337
394
|
if (method === "GET" && pathname === "/admin/users") {
|
|
338
395
|
if (!currentUser) {
|
|
339
396
|
return sendRedirect(response, "/login");
|
|
@@ -588,6 +645,23 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
588
645
|
return sendJson(response, { events }, 200, headOnly);
|
|
589
646
|
}
|
|
590
647
|
|
|
648
|
+
if (method === "GET" && pathname === "/api/admin/backup") {
|
|
649
|
+
requireAdminAuth(request.artifactyAuth);
|
|
650
|
+
return sendJson(response, await buildStoreBackup(store), 200, headOnly, {
|
|
651
|
+
"content-disposition": `attachment; filename="${backupFileName()}"`
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (method === "POST" && pathname === "/api/admin/backup/import") {
|
|
656
|
+
assertLocalOrigin(request);
|
|
657
|
+
requireAdminAuth(request.artifactyAuth);
|
|
658
|
+
const body = await readJsonBody(request, MAX_BACKUP_BYTES);
|
|
659
|
+
const result = typeof body.backup === "string"
|
|
660
|
+
? await importStoreFromString(store, body.backup)
|
|
661
|
+
: await importStoreBundle(store, Array.isArray(body.artifacts) ? body : body.backup);
|
|
662
|
+
return sendJson(response, result);
|
|
663
|
+
}
|
|
664
|
+
|
|
591
665
|
if (method === "POST" && pathname === "/api/artifacts") {
|
|
592
666
|
assertLocalOrigin(request);
|
|
593
667
|
const body = await readJsonBody(request);
|
|
@@ -835,8 +909,8 @@ function paginationJson(page) {
|
|
|
835
909
|
};
|
|
836
910
|
}
|
|
837
911
|
|
|
838
|
-
export async function readJsonBody(request) {
|
|
839
|
-
const raw = await readBody(request,
|
|
912
|
+
export async function readJsonBody(request, limitBytes = MAX_ARTIFACT_BYTES + 1024) {
|
|
913
|
+
const raw = await readBody(request, limitBytes);
|
|
840
914
|
if (!raw.trim()) {
|
|
841
915
|
return {};
|
|
842
916
|
}
|
|
@@ -850,8 +924,8 @@ export async function readJsonBody(request) {
|
|
|
850
924
|
}
|
|
851
925
|
}
|
|
852
926
|
|
|
853
|
-
export async function readFormBody(request) {
|
|
854
|
-
const raw = await readBody(request,
|
|
927
|
+
export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 1024) {
|
|
928
|
+
const raw = await readBody(request, limitBytes);
|
|
855
929
|
const params = new URLSearchParams(raw);
|
|
856
930
|
return Object.fromEntries(params.entries());
|
|
857
931
|
}
|
|
@@ -903,15 +977,30 @@ export function assertLocalOrigin(request) {
|
|
|
903
977
|
}
|
|
904
978
|
}
|
|
905
979
|
|
|
906
|
-
export function sendJson(response, data, statusCode = 200, headOnly = false) {
|
|
980
|
+
export function sendJson(response, data, statusCode = 200, headOnly = false, headers = {}) {
|
|
907
981
|
response.writeHead(statusCode, {
|
|
908
982
|
"content-type": "application/json; charset=utf-8",
|
|
909
983
|
"cache-control": "no-store",
|
|
910
|
-
"x-content-type-options": "nosniff"
|
|
984
|
+
"x-content-type-options": "nosniff",
|
|
985
|
+
...headers
|
|
911
986
|
});
|
|
912
987
|
response.end(headOnly ? undefined : `${JSON.stringify(data, null, 2)}\n`);
|
|
913
988
|
}
|
|
914
989
|
|
|
990
|
+
function sendBackupDownload(response, content, headOnly = false) {
|
|
991
|
+
response.writeHead(200, {
|
|
992
|
+
"content-type": "application/json; charset=utf-8",
|
|
993
|
+
"content-disposition": `attachment; filename="${backupFileName()}"`,
|
|
994
|
+
"cache-control": "no-store",
|
|
995
|
+
"x-content-type-options": "nosniff"
|
|
996
|
+
});
|
|
997
|
+
response.end(headOnly ? undefined : content);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function backupFileName() {
|
|
1001
|
+
return `artifacty-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
915
1004
|
export function sendHtml(response, html, statusCode = 200, headOnly = false, contentSecurityPolicy = defaultContentSecurityPolicy()) {
|
|
916
1005
|
response.writeHead(statusCode, {
|
|
917
1006
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -1100,6 +1189,15 @@ function requireAdmin(user) {
|
|
|
1100
1189
|
}
|
|
1101
1190
|
}
|
|
1102
1191
|
|
|
1192
|
+
function requireAdminAuth(auth) {
|
|
1193
|
+
if (auth?.role !== "admin" && auth?.user?.role !== "admin") {
|
|
1194
|
+
throw Object.assign(new Error("Artifacty admin privileges required"), {
|
|
1195
|
+
code: "ADMIN_REQUIRED",
|
|
1196
|
+
statusCode: 403
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1103
1201
|
async function sessionUserFromRequest(store, request) {
|
|
1104
1202
|
return getSessionUser(store, sessionTokenFromRequest(request));
|
|
1105
1203
|
}
|