artifacty 0.10.7 → 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 +14 -0
- package/docs/central-team-deployment-design.md +16 -1
- package/docs/integrations.md +12 -2
- package/package.json +1 -1
- package/src/lib/backup.js +139 -12
- package/src/lib/render.js +76 -2
- package/src/lib/service.js +2 -0
- package/src/lib/storage.js +51 -28
- package/src/server.js +104 -6
package/README.md
CHANGED
|
@@ -92,6 +92,10 @@ artifacty serve --host 10.0.0.50 --share-mode team --api-token "$ARTIFACTY_BOOTS
|
|
|
92
92
|
artifacty install all --mcp-url http://10.0.0.50:8787/mcp --api-token "$ARTIFACTY_PERSONAL_TOKEN"
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
When running the central server as a Linux `systemctl --user` service, enable
|
|
96
|
+
lingering for the service account so Artifacty stays up after logout:
|
|
97
|
+
`sudo loginctl enable-linger $USER`.
|
|
98
|
+
|
|
95
99
|
Administrators can create users individually at `/admin/users` or import them
|
|
96
100
|
from CSV. Use `email,name,role,password,password_reset_required` headers. If
|
|
97
101
|
`password` is empty, Artifacty generates a temporary password, shows it once in
|
|
@@ -254,6 +258,13 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
|
|
|
254
258
|
|
|
255
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.
|
|
256
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
|
+
|
|
257
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:
|
|
258
269
|
|
|
259
270
|
```bash
|
|
@@ -305,6 +316,9 @@ Browser routes:
|
|
|
305
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.
|
|
306
317
|
- `/artifacts/:id/diff`: compare versions.
|
|
307
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.
|
|
308
322
|
- `/api/audit`: list audit events.
|
|
309
323
|
|
|
310
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,22 @@ 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.
|
|
138
|
+
|
|
139
|
+
For Linux user-service deployments, enable systemd lingering for the service
|
|
140
|
+
account:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
sudo loginctl enable-linger artifacty
|
|
144
|
+
loginctl show-user artifacty -p Linger
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Use the actual service account name. Without lingering, `systemctl --user`
|
|
148
|
+
services are tied to the user's login manager and can stop after logout, making
|
|
149
|
+
the central dashboard and `/mcp` endpoint unavailable until that user logs in
|
|
150
|
+
again. A system-level unit is another valid option for central servers.
|
|
136
151
|
|
|
137
152
|
The central server exposes `/mcp` only when explicitly enabled. This keeps the
|
|
138
153
|
local browser/API server behavior unchanged while making team MCP exposure an
|
package/docs/integrations.md
CHANGED
|
@@ -393,13 +393,19 @@ The generated service runs `src/server.js` with explicit `--host` and `--home` a
|
|
|
393
393
|
Activation is still delegated to the OS service manager:
|
|
394
394
|
|
|
395
395
|
- macOS: `launchctl load ~/Library/LaunchAgents/com.artifacty.server.plist`
|
|
396
|
-
- Linux: `systemctl --user enable --now com.artifacty.server.service`
|
|
396
|
+
- Linux: `sudo loginctl enable-linger $USER`, then `systemctl --user enable --now com.artifacty.server.service`
|
|
397
397
|
- Windows: run the generated PowerShell script, which registers and starts the `ArtifactyServer` scheduled task.
|
|
398
398
|
|
|
399
|
+
On Linux central servers, user services stop when the user's systemd manager exits unless lingering is enabled. Verify durable operation with `loginctl show-user $USER -p Linger`; it should report `Linger=yes`. Without lingering, Artifacty may be unavailable after logout and appear to recover only after the user logs back in.
|
|
400
|
+
|
|
399
401
|
For background services, prefer a stable `ARTIFACTY_API_TOKEN` or `--api-token` value. `serve --generate-token` is intended for temporary interactive sessions; the parent CLI returns the generated token in JSON.
|
|
400
402
|
|
|
401
403
|
## Backup and Audit
|
|
402
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
|
+
|
|
403
409
|
```bash
|
|
404
410
|
node src/cli.js audit --limit 20
|
|
405
411
|
node src/cli.js backup
|
|
@@ -407,4 +413,8 @@ node src/cli.js export --file ./artifacty-backup.json
|
|
|
407
413
|
node src/cli.js import-store --file ./artifacty-backup.json
|
|
408
414
|
```
|
|
409
415
|
|
|
410
|
-
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/lib/service.js
CHANGED
|
@@ -184,9 +184,11 @@ function definitionForPlatform(platform, options = {}, renderOnly) {
|
|
|
184
184
|
path: servicePath(platform, options),
|
|
185
185
|
content: createSystemdUserUnit(options),
|
|
186
186
|
nextSteps: [
|
|
187
|
+
"sudo loginctl enable-linger $USER",
|
|
187
188
|
"systemctl --user daemon-reload",
|
|
188
189
|
`systemctl --user enable --now ${path.basename(servicePath(platform, options))}`,
|
|
189
190
|
`systemctl --user status ${path.basename(servicePath(platform, options))}`,
|
|
191
|
+
"loginctl show-user $USER -p Linger",
|
|
190
192
|
`journalctl --user -u ${path.basename(servicePath(platform, options))} -f`,
|
|
191
193
|
`systemctl --user disable --now ${path.basename(servicePath(platform, options))}`
|
|
192
194
|
],
|
package/src/lib/storage.js
CHANGED
|
@@ -10,6 +10,7 @@ import { assertNoSecrets, securityConfig } from "./security.js";
|
|
|
10
10
|
export const STORE_VERSION = 4;
|
|
11
11
|
export const ARTIFACT_SCHEMA_VERSION = 1;
|
|
12
12
|
export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
const SOURCE_AGENT_NORMALIZATION_VERSION = "1";
|
|
13
14
|
export const USER_ROLES = ["admin", "user"];
|
|
14
15
|
export const ARTIFACT_FORMATS = [
|
|
15
16
|
"html",
|
|
@@ -123,6 +124,11 @@ export async function writeIndex(store, index) {
|
|
|
123
124
|
insertVersionRecord(db, artifact.id, version);
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
normalizeStoredSourceAgents(db, store, {
|
|
128
|
+
force: true,
|
|
129
|
+
inTransaction: true,
|
|
130
|
+
rebuildSearch: false
|
|
131
|
+
});
|
|
126
132
|
rebuildSearchIndexInDb(db, store);
|
|
127
133
|
});
|
|
128
134
|
} finally {
|
|
@@ -1502,9 +1508,26 @@ function migrateJsonIndex(db, store) {
|
|
|
1502
1508
|
});
|
|
1503
1509
|
}
|
|
1504
1510
|
|
|
1511
|
+
function metaValue(db, key) {
|
|
1512
|
+
return db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value || "";
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1505
1515
|
const INFERABLE_SOURCE_AGENTS = new Set(["claude", "codex", "copilot", "cursor", "gemini"]);
|
|
1506
1516
|
|
|
1507
|
-
function normalizeStoredSourceAgents(db, store) {
|
|
1517
|
+
function normalizeStoredSourceAgents(db, store, options = {}) {
|
|
1518
|
+
if (!options.force && metaValue(db, "source_agent_normalized_version") === SOURCE_AGENT_NORMALIZATION_VERSION) {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const run = () => normalizeStoredSourceAgentsInDb(db, store, options);
|
|
1523
|
+
if (options.inTransaction) {
|
|
1524
|
+
run();
|
|
1525
|
+
} else {
|
|
1526
|
+
transaction(db, run);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function normalizeStoredSourceAgentsInDb(db, store, options = {}) {
|
|
1508
1531
|
const rows = db.prepare(`
|
|
1509
1532
|
SELECT id, source_agent, tags_json
|
|
1510
1533
|
FROM artifacts
|
|
@@ -1512,37 +1535,37 @@ function normalizeStoredSourceAgents(db, store) {
|
|
|
1512
1535
|
const updateArtifact = db.prepare("UPDATE artifacts SET source_agent = ?, tags_json = ? WHERE id = ?");
|
|
1513
1536
|
let changed = false;
|
|
1514
1537
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
changed = true;
|
|
1526
|
-
}
|
|
1538
|
+
for (const row of rows) {
|
|
1539
|
+
const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "unknown" });
|
|
1540
|
+
const inferred = isUnknownSourceAgent(normalized)
|
|
1541
|
+
? inferStoredSourceAgent(db, row)
|
|
1542
|
+
: normalized;
|
|
1543
|
+
const nextSourceAgent = inferred || normalized;
|
|
1544
|
+
const nextTags = normalizeStoredSourceTags(row.tags_json, nextSourceAgent);
|
|
1545
|
+
if (nextSourceAgent !== row.source_agent || nextTags !== row.tags_json) {
|
|
1546
|
+
updateArtifact.run(nextSourceAgent, nextTags, row.id);
|
|
1547
|
+
changed = true;
|
|
1527
1548
|
}
|
|
1549
|
+
}
|
|
1528
1550
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
}
|
|
1551
|
+
const auditRows = db.prepare(`
|
|
1552
|
+
SELECT id, source_agent
|
|
1553
|
+
FROM audit_log
|
|
1554
|
+
WHERE source_agent IS NOT NULL
|
|
1555
|
+
AND TRIM(source_agent) != ''
|
|
1556
|
+
`).all();
|
|
1557
|
+
const updateAudit = db.prepare("UPDATE audit_log SET source_agent = ? WHERE id = ?");
|
|
1558
|
+
for (const row of auditRows) {
|
|
1559
|
+
const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "" });
|
|
1560
|
+
if (normalized && normalized !== row.source_agent) {
|
|
1561
|
+
updateAudit.run(normalized, row.id);
|
|
1562
|
+
changed = true;
|
|
1542
1563
|
}
|
|
1543
|
-
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('source_agent_normalized_version', ?)").run(SOURCE_AGENT_NORMALIZATION_VERSION);
|
|
1544
1567
|
|
|
1545
|
-
if (changed) {
|
|
1568
|
+
if (changed && options.rebuildSearch !== false) {
|
|
1546
1569
|
rebuildSearchIndexInDb(db, store);
|
|
1547
1570
|
}
|
|
1548
1571
|
}
|
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
|
}
|