artifacty 0.10.5 → 0.10.7
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 +6 -4
- package/docs/artifact-schema-v1.md +10 -2
- package/docs/integrations.md +5 -2
- package/package.json +1 -1
- package/src/lib/agents.js +53 -0
- package/src/lib/converters.js +10 -26
- package/src/lib/render.js +80 -1
- package/src/lib/storage.js +334 -5
- package/src/server.js +63 -1
package/README.md
CHANGED
|
@@ -252,7 +252,7 @@ By default Artifacty stores files under `~/.artifacty`.
|
|
|
252
252
|
ARTIFACTY_HOME=/path/to/shared/store artifacty serve
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as
|
|
255
|
+
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
256
|
|
|
257
257
|
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
258
|
|
|
@@ -302,8 +302,9 @@ Browser routes:
|
|
|
302
302
|
- `/`: list artifacts with search, tag, and source filters.
|
|
303
303
|
- `/new`: create an Artifacty-native artifact with the CodeMirror editor.
|
|
304
304
|
- `/import`: paste an external agent artifact and convert it with automatic editor mode detection.
|
|
305
|
-
- `/artifacts/:id/edit`: save a new version with Markdown, HTML, JSON, text, code, SVG, Mermaid, React, SARIF, CSV, image, or video syntax support.
|
|
305
|
+
- `/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
306
|
- `/artifacts/:id/diff`: compare versions.
|
|
307
|
+
- `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
|
|
307
308
|
- `/api/audit`: list audit events.
|
|
308
309
|
|
|
309
310
|
List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
|
|
@@ -320,10 +321,11 @@ The browser UI defaults to English. Add `?lang=ko` to any browser route to use K
|
|
|
320
321
|
Schema and storage:
|
|
321
322
|
|
|
322
323
|
- Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, `publisherId`, and `archivedAt`.
|
|
323
|
-
- Archive hides artifacts from default lists without deleting versions.
|
|
324
|
+
- Archive hides artifacts from default lists without deleting versions. Admin version repair/delete is available for correcting accidental or sensitive historical versions and records `version-repair` or `version-delete` audit events.
|
|
324
325
|
- Bundle artifacts store multiple files or base64 assets as portable JSON.
|
|
325
326
|
- Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
|
|
326
327
|
- Native create/import paths infer `html` from HTML documents or fragments when no explicit format is supplied.
|
|
328
|
+
- `sourceAgent` is canonicalized before storage. Aliases such as `claude-code`, `Claude Code`, `github-copilot`, and `gemini-cli` are stored as `claude`, `copilot`, and `gemini`; legacy `unknown` rows are backfilled only when version metadata, audit data, or source-agent tags provide a known agent.
|
|
327
329
|
- Diagram, component, source snippet, analysis report, table, and media assets use `diagram`, `component`, `snippet`, `analysis-report`, `table`, and `asset` artifact types.
|
|
328
330
|
- Copilot/Cursor examples cover PR reviews, screenshots, demo recordings, and visual evidence bundles.
|
|
329
331
|
- See [docs/artifact-schema-v1.md](docs/artifact-schema-v1.md).
|
|
@@ -341,7 +343,7 @@ Schema and storage:
|
|
|
341
343
|
- Non-local sharing is intended for trusted LAN or VPN sessions. Prefer a specific interface IP over `0.0.0.0`, keep React rendering disabled, and see [docs/network-sharing.md](docs/network-sharing.md).
|
|
342
344
|
- Non-local binding prints a startup warning because Artifacty does not terminate TLS.
|
|
343
345
|
- Artifact content is scanned for common API keys and private keys before storage. Use `--allow-secrets` or `ARTIFACTY_ALLOW_SECRETS=true` only for intentional exceptions.
|
|
344
|
-
- Creates, updates, reads, imports, archives, and
|
|
346
|
+
- Creates, updates, reads, imports, archives, restores, no-op browser edits, and admin version repair/delete actions write audit events to SQLite. Legacy artifacts without a stored publisher are best-effort backfilled from their first `create` or `import` audit actor.
|
|
345
347
|
- CodeMirror editor/viewer and renderer assets are served from local npm dependencies through a package allowlist, not from a public CDN. JavaScript asset routes answer `Origin: null` requests with `Access-Control-Allow-Origin: null` so sandboxed renderer iframes can import local ESM without `allow-same-origin`.
|
|
346
348
|
- Mutating HTTP routes reject non-local browser origins.
|
|
347
349
|
- HTML artifacts render in a sandboxed iframe.
|
|
@@ -50,10 +50,18 @@ Artifacty store. For authenticated central servers this is the personal token
|
|
|
50
50
|
owner's email; `publisherName` and `publisherUserId` are included when the
|
|
51
51
|
server can map the request to a local user record. Legacy artifacts are
|
|
52
52
|
best-effort backfilled from the first `create` or `import` audit actor.
|
|
53
|
+
Known source-agent aliases are canonicalized before storage. For example,
|
|
54
|
+
`claude-code`, `Claude Code`, and `anthropic` become `claude`;
|
|
55
|
+
`github-copilot` becomes `copilot`; and `gemini-cli` becomes `gemini`.
|
|
56
|
+
Existing `unknown` rows are upgraded only when version metadata, audit data, or
|
|
57
|
+
source-agent tags provide one of the known agent identities.
|
|
53
58
|
|
|
54
59
|
## Version Record
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
Normal create and update flows append versions, and each version points at one
|
|
62
|
+
content file. Administrator repair/delete actions are exceptional maintenance
|
|
63
|
+
operations for correcting accidental or sensitive historical versions; they
|
|
64
|
+
write `version-repair` or `version-delete` audit events.
|
|
57
65
|
|
|
58
66
|
```json
|
|
59
67
|
{
|
|
@@ -115,7 +123,7 @@ Metadata is free-form JSON, but converter-generated metadata uses these keys:
|
|
|
115
123
|
|
|
116
124
|
## Archive Semantics
|
|
117
125
|
|
|
118
|
-
Artifacts are not deleted by P0 behavior. Archive sets `archivedAt` and hides the artifact from default list results. `includeArchived=true` includes archived records. Restore clears `archivedAt`.
|
|
126
|
+
Artifacts are not deleted by P0 behavior. Archive sets `archivedAt` and hides the artifact from default list results. `includeArchived=true` includes archived records. Restore clears `archivedAt`. Regular archive/restore leaves versions and content files unchanged. Administrators may repair or delete individual versions from `/admin/artifacts/:id/versions`; the last remaining version cannot be deleted.
|
|
119
127
|
|
|
120
128
|
## Bundle Format
|
|
121
129
|
|
package/docs/integrations.md
CHANGED
|
@@ -321,7 +321,7 @@ Supported converter inputs:
|
|
|
321
321
|
- Gemini: `returnDisplay`, `llmContent`, text blocks, or local markdown/text/json files.
|
|
322
322
|
- Generic: file extension, content type, HTML doctype/fragments, JSON shape, media data URLs, and markdown headings are used to infer format and title. Generic `text/plain` uploads are upgraded to a more specific format, such as `html`, when filename or content makes that clear.
|
|
323
323
|
|
|
324
|
-
The converter adds `imported` and source-agent tags, preserves the raw content as an immutable Artifacty version, and records source details under `metadata.artifactyImport`.
|
|
324
|
+
The converter canonicalizes source-agent aliases, adds `imported` and source-agent tags, preserves the raw content as an immutable Artifacty version, and records source details under `metadata.artifactyImport`. For example, `claude-code` and `Claude Code` are stored as `claude`.
|
|
325
325
|
|
|
326
326
|
Fixture examples for Copilot/Cursor PR review, screenshot, demo recording, and
|
|
327
327
|
visual evidence bundle live under `test/fixtures/` and are covered by converter
|
|
@@ -347,11 +347,14 @@ tests.
|
|
|
347
347
|
- `GET /artifacts/:id`: browser viewer.
|
|
348
348
|
- `GET /artifacts/:id/react-frame?version=n`: gated React renderer frame. Returns content only when `ARTIFACTY_ENABLE_REACT_RENDERER=true`.
|
|
349
349
|
- `GET /artifacts/:id/edit`: browser version editor.
|
|
350
|
-
- `POST /artifacts/:id/edit`: append a version from the browser editor.
|
|
350
|
+
- `POST /artifacts/:id/edit`: append a version from the browser editor. Unchanged browser edits record `update-noop` without creating another version.
|
|
351
351
|
- `POST /artifacts/:id/archive`: archive from the browser.
|
|
352
352
|
- `POST /artifacts/:id/restore`: restore from the browser.
|
|
353
353
|
- `GET /artifacts/:id/diff`: compare two versions.
|
|
354
354
|
- `GET /artifacts/:id/raw?version=n`: raw content.
|
|
355
|
+
- `GET /admin/artifacts/:id/versions`: administrator-only version repair/delete screen.
|
|
356
|
+
- `POST /admin/artifacts/:id/versions/:version/repair`: administrator-only replacement of one version's content and format.
|
|
357
|
+
- `POST /admin/artifacts/:id/versions/:version/delete`: administrator-only deletion of one version. The last remaining version cannot be deleted.
|
|
355
358
|
|
|
356
359
|
When `ARTIFACTY_API_TOKEN` is configured, `/api/*` routes require either `Authorization: Bearer <token>` or `x-artifacty-token: <token>`. When users exist, personal tokens issued from `/account` also authenticate API and MCP requests, set created artifacts' `publisherId` to the token owner's email, and map audit `actor` to the same identity. Browser forms can also carry `?token=<token>` in the URL, which is copied to hidden form fields for local team workflows.
|
|
357
360
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const KNOWN_SOURCE_AGENTS = new Set([
|
|
2
|
+
"artifacty",
|
|
3
|
+
"claude",
|
|
4
|
+
"codex",
|
|
5
|
+
"copilot",
|
|
6
|
+
"cursor",
|
|
7
|
+
"gemini",
|
|
8
|
+
"generic",
|
|
9
|
+
"mcp",
|
|
10
|
+
"unknown"
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const SOURCE_AGENT_ALIASES = new Map([
|
|
14
|
+
["anthropic", "claude"],
|
|
15
|
+
["claude-ai", "claude"],
|
|
16
|
+
["claude-code", "claude"],
|
|
17
|
+
["claude-code-cli", "claude"],
|
|
18
|
+
["claude_cli", "claude"],
|
|
19
|
+
["github-copilot", "copilot"],
|
|
20
|
+
["copilot-chat", "copilot"],
|
|
21
|
+
["vscode-copilot", "copilot"],
|
|
22
|
+
["vs-code-copilot", "copilot"],
|
|
23
|
+
["cursor-ai", "cursor"],
|
|
24
|
+
["gemini-cli", "gemini"],
|
|
25
|
+
["google", "gemini"],
|
|
26
|
+
["openai", "codex"],
|
|
27
|
+
["openai-codex", "codex"],
|
|
28
|
+
["codex-cli", "codex"],
|
|
29
|
+
["chatgpt", "codex"]
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function normalizeSourceAgent(value, options = {}) {
|
|
33
|
+
const defaultValue = options.defaultValue ?? "unknown";
|
|
34
|
+
const allowAuto = options.allowAuto === true;
|
|
35
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
36
|
+
if (!normalized) {
|
|
37
|
+
return defaultValue;
|
|
38
|
+
}
|
|
39
|
+
if (normalized === "auto") {
|
|
40
|
+
return allowAuto ? "auto" : defaultValue;
|
|
41
|
+
}
|
|
42
|
+
const slug = normalized.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
+
const canonical = SOURCE_AGENT_ALIASES.get(normalized) || SOURCE_AGENT_ALIASES.get(slug) || slug;
|
|
44
|
+
if (KNOWN_SOURCE_AGENTS.has(canonical)) {
|
|
45
|
+
return canonical;
|
|
46
|
+
}
|
|
47
|
+
return canonical || defaultValue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isUnknownSourceAgent(value) {
|
|
51
|
+
const normalized = normalizeSourceAgent(value, { defaultValue: "unknown" });
|
|
52
|
+
return normalized === "unknown" || normalized === "generic" || normalized === "auto";
|
|
53
|
+
}
|
package/src/lib/converters.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { normalizeSourceAgent } from "./agents.js";
|
|
3
4
|
import { ARTIFACT_TYPES, contentTypeForFormat, normalizeFormat } from "./storage.js";
|
|
4
5
|
|
|
5
6
|
const CONTINUATION_AGENTS = new Set(["codex", "copilot", "cursor"]);
|
|
6
|
-
const KNOWN_AGENTS = new Set(["auto", "artifacty", "claude", "codex", "copilot", "cursor", "gemini", "generic"]);
|
|
7
7
|
|
|
8
8
|
export function convertAgentArtifact(input = {}) {
|
|
9
9
|
const originalAgent = normalizeAgent(input.agent || input.sourceAgent || input.source_agent || "auto");
|
|
@@ -28,8 +28,11 @@ export function convertAgentArtifact(input = {}) {
|
|
|
28
28
|
if (media) {
|
|
29
29
|
content = media.content;
|
|
30
30
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
31
|
+
const explicitSourceAgent = optionalString(input.sourceAgent || input.source_agent || decoded.sourceAgent);
|
|
32
|
+
const detectedSourceAgent = originalAgent === "auto" ? detectAgent(parsed, fileName) : originalAgent;
|
|
33
|
+
const sourceAgent = explicitSourceAgent
|
|
34
|
+
? normalizeAgent(explicitSourceAgent)
|
|
35
|
+
: normalizeAgent(detectedSourceAgent || "unknown");
|
|
33
36
|
|
|
34
37
|
const title =
|
|
35
38
|
optionalString(input.title) ||
|
|
@@ -1387,29 +1390,10 @@ function cleanTitle(value) {
|
|
|
1387
1390
|
}
|
|
1388
1391
|
|
|
1389
1392
|
function normalizeAgent(value) {
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
}
|
|
1394
|
-
if (normalized === "claude-code" || normalized === "anthropic") {
|
|
1395
|
-
return "claude";
|
|
1396
|
-
}
|
|
1397
|
-
if (normalized === "gemini-cli" || normalized === "google") {
|
|
1398
|
-
return "gemini";
|
|
1399
|
-
}
|
|
1400
|
-
if (normalized === "github-copilot" || normalized === "copilot-chat" || normalized === "vscode-copilot" || normalized === "vs-code-copilot") {
|
|
1401
|
-
return "copilot";
|
|
1402
|
-
}
|
|
1403
|
-
if (normalized === "cursor-ai") {
|
|
1404
|
-
return "cursor";
|
|
1405
|
-
}
|
|
1406
|
-
if (normalized === "openai" || normalized === "chatgpt") {
|
|
1407
|
-
return "codex";
|
|
1408
|
-
}
|
|
1409
|
-
if (KNOWN_AGENTS.has(normalized)) {
|
|
1410
|
-
return normalized;
|
|
1411
|
-
}
|
|
1412
|
-
return normalized.replace(/[^a-z0-9-]+/g, "-") || "generic";
|
|
1393
|
+
return normalizeSourceAgent(value, {
|
|
1394
|
+
defaultValue: "auto",
|
|
1395
|
+
allowAuto: true
|
|
1396
|
+
});
|
|
1413
1397
|
}
|
|
1414
1398
|
|
|
1415
1399
|
function normalizeTags(tags) {
|
package/src/lib/render.js
CHANGED
|
@@ -189,6 +189,83 @@ export function renderNewArtifactPage({ baseUrl, authToken = "", locale = DEFAUL
|
|
|
189
189
|
return renderArtifactFormPage({ mode: "new", baseUrl, authToken, locale, currentPath });
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, content, baseUrl, user, error = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
|
|
193
|
+
const view = viewContext(locale, currentPath);
|
|
194
|
+
const artifactPath = `/artifacts/${encodeURIComponent(artifact.id)}`;
|
|
195
|
+
const versionRows = artifact.versions.map((version) => {
|
|
196
|
+
const active = version.version === selectedVersion.version ? " active" : "";
|
|
197
|
+
const deleteDisabled = artifact.versions.length <= 1 ? " disabled" : "";
|
|
198
|
+
return `
|
|
199
|
+
<tr>
|
|
200
|
+
<td><a class="version${active}" href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions?version=${version.version}`)}">v${version.version}</a></td>
|
|
201
|
+
<td>${formatBadge(version.format)}</td>
|
|
202
|
+
<td>${escapeHtml(version.createdAt)}</td>
|
|
203
|
+
<td><code>${escapeHtml(version.sha256.slice(0, 12))}</code></td>
|
|
204
|
+
<td>${escapeHtml(String(version.sizeBytes))}</td>
|
|
205
|
+
<td>
|
|
206
|
+
<form class="inline-action" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${version.version}/delete">
|
|
207
|
+
${localeInput(view.locale)}
|
|
208
|
+
<input name="reason" placeholder="Reason">
|
|
209
|
+
<button type="submit"${deleteDisabled}>Delete</button>
|
|
210
|
+
</form>
|
|
211
|
+
</td>
|
|
212
|
+
</tr>
|
|
213
|
+
`;
|
|
214
|
+
}).join("");
|
|
215
|
+
|
|
216
|
+
return pageShell({
|
|
217
|
+
title: `Manage versions · ${artifact.title}`,
|
|
218
|
+
body: `
|
|
219
|
+
<header class="topbar">
|
|
220
|
+
<div>
|
|
221
|
+
<h1>Manage versions</h1>
|
|
222
|
+
<p>${escapeHtml(artifact.title)} · ${escapeHtml(artifact.id)} · ${escapeHtml(baseUrl)}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<nav>
|
|
225
|
+
<a href="${view.href(artifactPath)}">Artifact</a>
|
|
226
|
+
<a href="${view.href(`${artifactPath}/diff`)}">${view.text("nav.diff")}</a>
|
|
227
|
+
${authNav(user)}
|
|
228
|
+
${languageSwitcher(view)}
|
|
229
|
+
</nav>
|
|
230
|
+
</header>
|
|
231
|
+
<main class="artifact-editor">
|
|
232
|
+
${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
|
|
233
|
+
<section class="meta-card">
|
|
234
|
+
<h2>Versions</h2>
|
|
235
|
+
<table class="data-table">
|
|
236
|
+
<thead><tr><th>Version</th><th>Format</th><th>Created</th><th>SHA</th><th>Bytes</th><th>Action</th></tr></thead>
|
|
237
|
+
<tbody>${versionRows}</tbody>
|
|
238
|
+
</table>
|
|
239
|
+
</section>
|
|
240
|
+
<form class="editor-form" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${selectedVersion.version}/repair">
|
|
241
|
+
${localeInput(view.locale)}
|
|
242
|
+
<section class="editor-fields">
|
|
243
|
+
<label class="field">
|
|
244
|
+
<span>${view.text("form.format")}</span>
|
|
245
|
+
${formatSelect(selectedVersion.format)}
|
|
246
|
+
</label>
|
|
247
|
+
<label class="field">
|
|
248
|
+
<span>Reason</span>
|
|
249
|
+
<input name="reason" value="" autocomplete="off">
|
|
250
|
+
</label>
|
|
251
|
+
</section>
|
|
252
|
+
<section class="field content-field">
|
|
253
|
+
<label for="artifact-content">v${selectedVersion.version} content</label>
|
|
254
|
+
<textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(selectedVersion.format)}" spellcheck="false" required>${escapeHtml(content)}</textarea>
|
|
255
|
+
</section>
|
|
256
|
+
<footer class="editor-actions">
|
|
257
|
+
<a href="${view.href(`${artifactPath}?version=${selectedVersion.version}`)}">View version</a>
|
|
258
|
+
<button type="submit">Repair version</button>
|
|
259
|
+
</footer>
|
|
260
|
+
</form>
|
|
261
|
+
</main>
|
|
262
|
+
`,
|
|
263
|
+
head: editorHead(),
|
|
264
|
+
afterBody: editorScript(view.locale),
|
|
265
|
+
locale: view.locale
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
192
269
|
export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/import" }) {
|
|
193
270
|
const view = viewContext(locale, currentPath);
|
|
194
271
|
const title = view.raw("form.importTitle");
|
|
@@ -509,7 +586,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
|
|
|
509
586
|
});
|
|
510
587
|
}
|
|
511
588
|
|
|
512
|
-
export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
|
|
589
|
+
export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
|
|
513
590
|
const view = viewContext(locale, currentPath);
|
|
514
591
|
const versionLinks = artifact.versions
|
|
515
592
|
.map((item) => {
|
|
@@ -543,7 +620,9 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
|
|
|
543
620
|
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
544
621
|
<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/edit`)}">${view.text("nav.edit")}</a>
|
|
545
622
|
<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/diff`)}">${view.text("nav.diff")}</a>
|
|
623
|
+
${user?.role === "admin" ? `<a href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions`)}">Versions</a>` : ""}
|
|
546
624
|
<a href="${view.href(rawUrl)}">${view.text("nav.raw")}</a>
|
|
625
|
+
${authNav(user)}
|
|
547
626
|
${languageSwitcher(view)}
|
|
548
627
|
</nav>
|
|
549
628
|
</header>
|
package/src/lib/storage.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { isUnknownSourceAgent, normalizeSourceAgent } from "./agents.js";
|
|
7
8
|
import { assertNoSecrets, securityConfig } from "./security.js";
|
|
8
9
|
|
|
9
10
|
export const STORE_VERSION = 4;
|
|
@@ -182,13 +183,32 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
182
183
|
export async function updateArtifact(store = createStore(), id, input = {}) {
|
|
183
184
|
const secretScan = assertNoSecrets(input, securityConfig());
|
|
184
185
|
input = withSecretScan(input, secretScan);
|
|
185
|
-
const normalized = normalizeArtifactInput(input, {
|
|
186
|
+
const normalized = normalizeArtifactInput(input, {
|
|
187
|
+
requireContent: true,
|
|
188
|
+
requireTitle: false,
|
|
189
|
+
defaultSourceAgent: ""
|
|
190
|
+
});
|
|
186
191
|
const db = openDatabase(store);
|
|
187
192
|
|
|
188
193
|
try {
|
|
189
194
|
let artifact;
|
|
190
195
|
transaction(db, () => {
|
|
191
196
|
artifact = findArtifactById(db, id);
|
|
197
|
+
const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
|
|
198
|
+
if (input.skipNoop && latest) {
|
|
199
|
+
const latestContent = readFileSync(path.join(store.home, latest.path), "utf8");
|
|
200
|
+
if (isNoopVersionUpdate(artifact, latest, latestContent, normalized)) {
|
|
201
|
+
insertAuditRecord(db, {
|
|
202
|
+
action: "update-noop",
|
|
203
|
+
artifactId: artifact.id,
|
|
204
|
+
version: artifact.latestVersion,
|
|
205
|
+
sourceAgent: artifact.sourceAgent,
|
|
206
|
+
audit: input.audit,
|
|
207
|
+
metadata: { reason: "no changes detected" }
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
192
212
|
const now = new Date().toISOString();
|
|
193
213
|
const nextVersion = artifact.latestVersion + 1;
|
|
194
214
|
const version = writeVersionFile(store, artifact.id, nextVersion, normalized, now);
|
|
@@ -234,6 +254,163 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
|
|
|
234
254
|
}
|
|
235
255
|
}
|
|
236
256
|
|
|
257
|
+
export async function replaceArtifactVersion(store = createStore(), id, versionNumber, input = {}) {
|
|
258
|
+
const secretScan = assertNoSecrets(input, securityConfig());
|
|
259
|
+
input = withSecretScan(input, secretScan);
|
|
260
|
+
const normalized = normalizeArtifactInput(input, {
|
|
261
|
+
requireContent: true,
|
|
262
|
+
requireTitle: false,
|
|
263
|
+
defaultSourceAgent: ""
|
|
264
|
+
});
|
|
265
|
+
const targetVersion = Number(versionNumber);
|
|
266
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
267
|
+
throw Object.assign(new Error(`Invalid artifact version: ${versionNumber}`), {
|
|
268
|
+
code: "INVALID_VERSION",
|
|
269
|
+
statusCode: 400
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const db = openDatabase(store);
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
let artifact;
|
|
276
|
+
transaction(db, () => {
|
|
277
|
+
artifact = findArtifactById(db, id);
|
|
278
|
+
const existing = artifact.versions.find((version) => version.version === targetVersion);
|
|
279
|
+
if (!existing) {
|
|
280
|
+
throw Object.assign(new Error(`Artifact version not found: ${id}@${targetVersion}`), {
|
|
281
|
+
code: "ARTIFACT_VERSION_NOT_FOUND",
|
|
282
|
+
statusCode: 404
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const now = new Date().toISOString();
|
|
287
|
+
const previousPath = existing.path;
|
|
288
|
+
const repairedMetadata = {
|
|
289
|
+
...existing.metadata,
|
|
290
|
+
...normalized.metadata,
|
|
291
|
+
adminRepair: {
|
|
292
|
+
repairedAt: now,
|
|
293
|
+
reason: normalizeOptionalString(input.reason),
|
|
294
|
+
previousSha256: existing.sha256,
|
|
295
|
+
previousSizeBytes: existing.sizeBytes,
|
|
296
|
+
previousPath
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const replacement = writeVersionFile(store, artifact.id, targetVersion, {
|
|
300
|
+
...normalized,
|
|
301
|
+
metadata: repairedMetadata
|
|
302
|
+
}, existing.createdAt);
|
|
303
|
+
|
|
304
|
+
if (previousPath !== replacement.path) {
|
|
305
|
+
removeVersionFile(store, previousPath);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
db.prepare(`
|
|
309
|
+
UPDATE artifact_versions
|
|
310
|
+
SET created_at = ?, format = ?, content_type = ?, path = ?, size_bytes = ?, sha256 = ?, metadata_json = ?
|
|
311
|
+
WHERE artifact_id = ? AND version = ?
|
|
312
|
+
`).run(
|
|
313
|
+
replacement.createdAt,
|
|
314
|
+
replacement.format,
|
|
315
|
+
replacement.contentType,
|
|
316
|
+
replacement.path,
|
|
317
|
+
replacement.sizeBytes,
|
|
318
|
+
replacement.sha256,
|
|
319
|
+
JSON.stringify(replacement.metadata || {}),
|
|
320
|
+
artifact.id,
|
|
321
|
+
targetVersion
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
artifact.versions = artifact.versions.map((version) => version.version === targetVersion ? replacement : version);
|
|
325
|
+
artifact.updatedAt = now;
|
|
326
|
+
db.prepare("UPDATE artifacts SET updated_at = ? WHERE id = ?").run(now, artifact.id);
|
|
327
|
+
if (targetVersion === artifact.latestVersion) {
|
|
328
|
+
upsertSearchIndex(db, artifact, replacement, normalized.content);
|
|
329
|
+
}
|
|
330
|
+
insertAuditRecord(db, {
|
|
331
|
+
action: "version-repair",
|
|
332
|
+
artifactId: artifact.id,
|
|
333
|
+
version: targetVersion,
|
|
334
|
+
sourceAgent: artifact.sourceAgent,
|
|
335
|
+
audit: input.audit,
|
|
336
|
+
metadata: {
|
|
337
|
+
reason: normalizeOptionalString(input.reason),
|
|
338
|
+
previousSha256: existing.sha256,
|
|
339
|
+
newSha256: replacement.sha256,
|
|
340
|
+
previousPath
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return getArtifact(store, id, { version: targetVersion });
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function deleteArtifactVersion(store = createStore(), id, versionNumber, options = {}) {
|
|
352
|
+
const targetVersion = Number(versionNumber);
|
|
353
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
354
|
+
throw Object.assign(new Error(`Invalid artifact version: ${versionNumber}`), {
|
|
355
|
+
code: "INVALID_VERSION",
|
|
356
|
+
statusCode: 400
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const db = openDatabase(store);
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
let artifact;
|
|
363
|
+
transaction(db, () => {
|
|
364
|
+
artifact = findArtifactById(db, id);
|
|
365
|
+
if (artifact.versions.length <= 1) {
|
|
366
|
+
throw Object.assign(new Error("Cannot delete the only version of an artifact"), {
|
|
367
|
+
code: "ONLY_VERSION_DELETE_BLOCKED",
|
|
368
|
+
statusCode: 400
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const existing = artifact.versions.find((version) => version.version === targetVersion);
|
|
372
|
+
if (!existing) {
|
|
373
|
+
throw Object.assign(new Error(`Artifact version not found: ${id}@${targetVersion}`), {
|
|
374
|
+
code: "ARTIFACT_VERSION_NOT_FOUND",
|
|
375
|
+
statusCode: 404
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const now = new Date().toISOString();
|
|
380
|
+
db.prepare("DELETE FROM artifact_versions WHERE artifact_id = ? AND version = ?").run(artifact.id, targetVersion);
|
|
381
|
+
removeVersionFile(store, existing.path);
|
|
382
|
+
artifact.versions = artifact.versions.filter((version) => version.version !== targetVersion);
|
|
383
|
+
artifact.latestVersion = Math.max(...artifact.versions.map((version) => version.version));
|
|
384
|
+
artifact.updatedAt = now;
|
|
385
|
+
db.prepare("UPDATE artifacts SET latest_version = ?, updated_at = ? WHERE id = ?").run(
|
|
386
|
+
artifact.latestVersion,
|
|
387
|
+
now,
|
|
388
|
+
artifact.id
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
|
|
392
|
+
const latestContent = readFileSync(path.join(store.home, latest.path), "utf8");
|
|
393
|
+
upsertSearchIndex(db, artifact, latest, latestContent);
|
|
394
|
+
insertAuditRecord(db, {
|
|
395
|
+
action: "version-delete",
|
|
396
|
+
artifactId: artifact.id,
|
|
397
|
+
version: targetVersion,
|
|
398
|
+
sourceAgent: artifact.sourceAgent,
|
|
399
|
+
audit: options.audit,
|
|
400
|
+
metadata: {
|
|
401
|
+
reason: normalizeOptionalString(options.reason),
|
|
402
|
+
deletedSha256: existing.sha256,
|
|
403
|
+
deletedPath: existing.path
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return withLatestContent(store, artifact);
|
|
409
|
+
} finally {
|
|
410
|
+
db.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
237
414
|
export async function archiveArtifact(store = createStore(), id, options = {}) {
|
|
238
415
|
const db = openDatabase(store);
|
|
239
416
|
try {
|
|
@@ -299,7 +476,7 @@ export async function listArtifactsPage(store = createStore(), filters = {}) {
|
|
|
299
476
|
const query = normalizeOptionalString(filters.query);
|
|
300
477
|
const normalizedQuery = query.toLowerCase();
|
|
301
478
|
const tag = normalizeOptionalString(filters.tag).toLowerCase();
|
|
302
|
-
const sourceAgent =
|
|
479
|
+
const sourceAgent = normalizeSourceAgent(filters.sourceAgent, { defaultValue: "" }).toLowerCase();
|
|
303
480
|
|
|
304
481
|
try {
|
|
305
482
|
if (query && searchIndexAvailable(db)) {
|
|
@@ -1082,6 +1259,7 @@ function openDatabase(store) {
|
|
|
1082
1259
|
`);
|
|
1083
1260
|
initializeSchema(db);
|
|
1084
1261
|
migrateJsonIndex(db, store);
|
|
1262
|
+
normalizeStoredSourceAgents(db, store);
|
|
1085
1263
|
syncSearchIndexIfEmpty(db, store);
|
|
1086
1264
|
return db;
|
|
1087
1265
|
}
|
|
@@ -1324,6 +1502,123 @@ function migrateJsonIndex(db, store) {
|
|
|
1324
1502
|
});
|
|
1325
1503
|
}
|
|
1326
1504
|
|
|
1505
|
+
const INFERABLE_SOURCE_AGENTS = new Set(["claude", "codex", "copilot", "cursor", "gemini"]);
|
|
1506
|
+
|
|
1507
|
+
function normalizeStoredSourceAgents(db, store) {
|
|
1508
|
+
const rows = db.prepare(`
|
|
1509
|
+
SELECT id, source_agent, tags_json
|
|
1510
|
+
FROM artifacts
|
|
1511
|
+
`).all();
|
|
1512
|
+
const updateArtifact = db.prepare("UPDATE artifacts SET source_agent = ?, tags_json = ? WHERE id = ?");
|
|
1513
|
+
let changed = false;
|
|
1514
|
+
|
|
1515
|
+
transaction(db, () => {
|
|
1516
|
+
for (const row of rows) {
|
|
1517
|
+
const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "unknown" });
|
|
1518
|
+
const inferred = isUnknownSourceAgent(normalized)
|
|
1519
|
+
? inferStoredSourceAgent(db, row)
|
|
1520
|
+
: normalized;
|
|
1521
|
+
const nextSourceAgent = inferred || normalized;
|
|
1522
|
+
const nextTags = normalizeStoredSourceTags(row.tags_json, nextSourceAgent);
|
|
1523
|
+
if (nextSourceAgent !== row.source_agent || nextTags !== row.tags_json) {
|
|
1524
|
+
updateArtifact.run(nextSourceAgent, nextTags, row.id);
|
|
1525
|
+
changed = true;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const auditRows = db.prepare(`
|
|
1530
|
+
SELECT id, source_agent
|
|
1531
|
+
FROM audit_log
|
|
1532
|
+
WHERE source_agent IS NOT NULL
|
|
1533
|
+
AND TRIM(source_agent) != ''
|
|
1534
|
+
`).all();
|
|
1535
|
+
const updateAudit = db.prepare("UPDATE audit_log SET source_agent = ? WHERE id = ?");
|
|
1536
|
+
for (const row of auditRows) {
|
|
1537
|
+
const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "" });
|
|
1538
|
+
if (normalized && normalized !== row.source_agent) {
|
|
1539
|
+
updateAudit.run(normalized, row.id);
|
|
1540
|
+
changed = true;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
if (changed) {
|
|
1546
|
+
rebuildSearchIndexInDb(db, store);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function inferStoredSourceAgent(db, artifactRow) {
|
|
1551
|
+
const tagCandidate = firstInferableSourceAgent(parseJson(artifactRow.tags_json, []));
|
|
1552
|
+
if (tagCandidate) {
|
|
1553
|
+
return tagCandidate;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const versionRows = db.prepare(`
|
|
1557
|
+
SELECT metadata_json
|
|
1558
|
+
FROM artifact_versions
|
|
1559
|
+
WHERE artifact_id = ?
|
|
1560
|
+
ORDER BY version DESC
|
|
1561
|
+
`).all(artifactRow.id);
|
|
1562
|
+
for (const row of versionRows) {
|
|
1563
|
+
const metadata = parseJson(row.metadata_json, {});
|
|
1564
|
+
const candidate = firstInferableSourceAgent([
|
|
1565
|
+
metadata.sourceAgent,
|
|
1566
|
+
metadata.source_agent,
|
|
1567
|
+
metadata.agent,
|
|
1568
|
+
metadata.artifactyImport?.sourceAgent,
|
|
1569
|
+
metadata.artifactyImport?.originalAgent
|
|
1570
|
+
]);
|
|
1571
|
+
if (candidate) {
|
|
1572
|
+
return candidate;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const auditRows = db.prepare(`
|
|
1577
|
+
SELECT source_agent
|
|
1578
|
+
FROM audit_log
|
|
1579
|
+
WHERE artifact_id = ?
|
|
1580
|
+
AND source_agent IS NOT NULL
|
|
1581
|
+
AND TRIM(source_agent) != ''
|
|
1582
|
+
ORDER BY created_at DESC
|
|
1583
|
+
`).all(artifactRow.id);
|
|
1584
|
+
return firstInferableSourceAgent(auditRows.map((row) => row.source_agent));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function normalizeStoredSourceTags(tagsJson, sourceAgent) {
|
|
1588
|
+
const tags = parseJson(tagsJson, []);
|
|
1589
|
+
if (!Array.isArray(tags)) {
|
|
1590
|
+
return JSON.stringify([]);
|
|
1591
|
+
}
|
|
1592
|
+
const normalizedTags = [];
|
|
1593
|
+
for (const tag of tags) {
|
|
1594
|
+
const trimmed = normalizeOptionalString(tag);
|
|
1595
|
+
if (!trimmed) {
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
const canonical = inferableSourceAgent(trimmed);
|
|
1599
|
+
const next = canonical || (trimmed === "unknown" && INFERABLE_SOURCE_AGENTS.has(sourceAgent) ? sourceAgent : trimmed);
|
|
1600
|
+
if (!normalizedTags.includes(next)) {
|
|
1601
|
+
normalizedTags.push(next);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return JSON.stringify(normalizedTags);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function firstInferableSourceAgent(values) {
|
|
1608
|
+
for (const value of values || []) {
|
|
1609
|
+
const candidate = inferableSourceAgent(value);
|
|
1610
|
+
if (candidate) {
|
|
1611
|
+
return candidate;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return "";
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function inferableSourceAgent(value) {
|
|
1618
|
+
const normalized = normalizeSourceAgent(value, { defaultValue: "" });
|
|
1619
|
+
return INFERABLE_SOURCE_AGENTS.has(normalized) ? normalized : "";
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1327
1622
|
function ensureSearchTable(db) {
|
|
1328
1623
|
try {
|
|
1329
1624
|
const existing = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artifact_search'").get();
|
|
@@ -1547,7 +1842,7 @@ function insertArtifactRecord(db, artifact) {
|
|
|
1547
1842
|
artifact.title,
|
|
1548
1843
|
normalizeArtifactType(artifact.artifactType || artifact.artifact_type || "document"),
|
|
1549
1844
|
normalizeSchemaVersion(artifact.schemaVersion || artifact.schema_version),
|
|
1550
|
-
artifact.sourceAgent || artifact.source_agent
|
|
1845
|
+
normalizeSourceAgent(artifact.sourceAgent || artifact.source_agent, { defaultValue: "unknown" }),
|
|
1551
1846
|
normalizeOptionalString(artifact.publisherId || artifact.publisher_id) || null,
|
|
1552
1847
|
normalizeOptionalString(artifact.publisherName || artifact.publisher_name) || null,
|
|
1553
1848
|
normalizeOptionalString(artifact.publisherUserId || artifact.publisher_user_id) || null,
|
|
@@ -1691,6 +1986,38 @@ function writeVersionFile(store, id, versionNumber, input, createdAt) {
|
|
|
1691
1986
|
};
|
|
1692
1987
|
}
|
|
1693
1988
|
|
|
1989
|
+
function removeVersionFile(store, relativePath) {
|
|
1990
|
+
const absolutePath = path.join(store.home, relativePath);
|
|
1991
|
+
if (existsSync(absolutePath)) {
|
|
1992
|
+
rmSync(absolutePath, { force: true });
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function isNoopVersionUpdate(artifact, latest, latestContent, normalized) {
|
|
1997
|
+
const nextTitle = normalized.title || artifact.title;
|
|
1998
|
+
const nextSourceAgent = normalized.sourceAgent || artifact.sourceAgent;
|
|
1999
|
+
const nextArtifactType = normalized.artifactType || artifact.artifactType;
|
|
2000
|
+
const nextSchemaVersion = normalized.schemaVersion || artifact.schemaVersion;
|
|
2001
|
+
const nextTags = normalized.tags.length > 0 ? normalized.tags : artifact.tags;
|
|
2002
|
+
const nextContentType = normalized.contentType || contentTypeForFormat(normalized.format);
|
|
2003
|
+
|
|
2004
|
+
return nextTitle === artifact.title &&
|
|
2005
|
+
nextSourceAgent === artifact.sourceAgent &&
|
|
2006
|
+
nextArtifactType === artifact.artifactType &&
|
|
2007
|
+
nextSchemaVersion === artifact.schemaVersion &&
|
|
2008
|
+
tagsEqual(nextTags, artifact.tags) &&
|
|
2009
|
+
normalized.content === latestContent &&
|
|
2010
|
+
normalized.format === latest.format &&
|
|
2011
|
+
nextContentType === latest.contentType;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function tagsEqual(left = [], right = []) {
|
|
2015
|
+
if (left.length !== right.length) {
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
return left.every((item, index) => item === right[index]);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1694
2021
|
async function withLatestContent(store, artifact) {
|
|
1695
2022
|
const latest = await readArtifactVersion(store, artifact, artifact.latestVersion);
|
|
1696
2023
|
return {
|
|
@@ -1725,7 +2052,9 @@ function normalizeArtifactInput(input, options) {
|
|
|
1725
2052
|
contentType: normalizeOptionalString(input.contentType),
|
|
1726
2053
|
artifactType: normalizeArtifactType(input.artifactType || input.artifact_type || inferArtifactType(input)),
|
|
1727
2054
|
schemaVersion: normalizeSchemaVersion(input.schemaVersion || input.schema_version),
|
|
1728
|
-
sourceAgent:
|
|
2055
|
+
sourceAgent: normalizeSourceAgent(input.sourceAgent || input.source_agent || input.agent, {
|
|
2056
|
+
defaultValue: options.defaultSourceAgent ?? "unknown"
|
|
2057
|
+
}),
|
|
1729
2058
|
tags: normalizeTags(input.tags),
|
|
1730
2059
|
metadata: normalizeMetadata(input.metadata)
|
|
1731
2060
|
};
|
package/src/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createSession,
|
|
15
15
|
createStore,
|
|
16
16
|
createUser,
|
|
17
|
+
deleteArtifactVersion,
|
|
17
18
|
getArtifact,
|
|
18
19
|
getSessionUser,
|
|
19
20
|
importUsersFromCsv,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
revokeApiToken,
|
|
26
27
|
revokeSession,
|
|
27
28
|
restoreArtifact,
|
|
29
|
+
replaceArtifactVersion,
|
|
28
30
|
setUserActive,
|
|
29
31
|
updateArtifact,
|
|
30
32
|
verifyUserPassword
|
|
@@ -41,6 +43,7 @@ import {
|
|
|
41
43
|
renderArtifactFormPage,
|
|
42
44
|
renderArtifactPage,
|
|
43
45
|
renderAccountPage,
|
|
46
|
+
renderAdminArtifactVersionsPage,
|
|
44
47
|
renderAdminUsersPage,
|
|
45
48
|
renderPasswordPage,
|
|
46
49
|
renderReactFramePage,
|
|
@@ -410,6 +413,63 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
410
413
|
return sendRedirect(response, "/admin/users");
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
const adminVersionsMatch = /^\/admin\/artifacts\/([^/]+)\/versions$/.exec(pathname);
|
|
417
|
+
if (adminVersionsMatch && method === "GET") {
|
|
418
|
+
if (!currentUser) {
|
|
419
|
+
return sendRedirect(response, "/login");
|
|
420
|
+
}
|
|
421
|
+
requireAdmin(currentUser);
|
|
422
|
+
const artifact = await getArtifact(store, adminVersionsMatch[1], {
|
|
423
|
+
version: url.searchParams.get("version") || undefined
|
|
424
|
+
});
|
|
425
|
+
return sendHtml(response, renderAdminArtifactVersionsPage({
|
|
426
|
+
artifact,
|
|
427
|
+
selectedVersion: artifact.version,
|
|
428
|
+
content: artifact.content,
|
|
429
|
+
baseUrl,
|
|
430
|
+
user: currentUser,
|
|
431
|
+
locale,
|
|
432
|
+
currentPath
|
|
433
|
+
}), 200, headOnly);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const adminVersionActionMatch = /^\/admin\/artifacts\/([^/]+)\/versions\/(\d+)\/(repair|delete)$/.exec(pathname);
|
|
437
|
+
if (adminVersionActionMatch && method === "POST") {
|
|
438
|
+
assertLocalOrigin(request);
|
|
439
|
+
if (!currentUser) {
|
|
440
|
+
return sendRedirect(response, "/login");
|
|
441
|
+
}
|
|
442
|
+
requireAdmin(currentUser);
|
|
443
|
+
request.artifactyAuth = {
|
|
444
|
+
type: "session",
|
|
445
|
+
actor: currentUser.email,
|
|
446
|
+
user: currentUser,
|
|
447
|
+
role: currentUser.role
|
|
448
|
+
};
|
|
449
|
+
const body = await readFormBody(request);
|
|
450
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
451
|
+
const artifactId = adminVersionActionMatch[1];
|
|
452
|
+
const versionNumber = Number(adminVersionActionMatch[2]);
|
|
453
|
+
const action = adminVersionActionMatch[3];
|
|
454
|
+
if (action === "repair") {
|
|
455
|
+
await replaceArtifactVersion(store, artifactId, versionNumber, {
|
|
456
|
+
content: body.content,
|
|
457
|
+
format: body.format,
|
|
458
|
+
reason: body.reason,
|
|
459
|
+
metadata: {
|
|
460
|
+
repairedVia: "artifacty-admin"
|
|
461
|
+
},
|
|
462
|
+
audit: auditContext(request, "web-admin")
|
|
463
|
+
});
|
|
464
|
+
} else {
|
|
465
|
+
await deleteArtifactVersion(store, artifactId, versionNumber, {
|
|
466
|
+
reason: body.reason,
|
|
467
|
+
audit: auditContext(request, "web-admin")
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return sendRedirect(response, localizedHref(`/admin/artifacts/${encodeURIComponent(artifactId)}/versions`, bodyLocale));
|
|
471
|
+
}
|
|
472
|
+
|
|
413
473
|
if (pathname === "/mcp") {
|
|
414
474
|
if (!mcpHttp) {
|
|
415
475
|
return sendJson(response, { error: "Not found" }, 404, headOnly);
|
|
@@ -585,6 +645,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
585
645
|
metadata: {
|
|
586
646
|
updatedVia: "artifacty-web"
|
|
587
647
|
},
|
|
648
|
+
skipNoop: true,
|
|
588
649
|
audit: auditContext(request, "web")
|
|
589
650
|
});
|
|
590
651
|
return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
|
|
@@ -697,7 +758,8 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
697
758
|
baseUrl,
|
|
698
759
|
authToken,
|
|
699
760
|
locale,
|
|
700
|
-
currentPath
|
|
761
|
+
currentPath,
|
|
762
|
+
user: currentUser
|
|
701
763
|
}), 200, headOnly);
|
|
702
764
|
}
|
|
703
765
|
|