artifacty 0.1.0
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/AGENTS.md +52 -0
- package/CLAUDE.md +64 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/docs/artifact-schema-v1.md +96 -0
- package/docs/assets/artifacty.png +0 -0
- package/docs/integrations.md +196 -0
- package/docs/release-checklist.md +30 -0
- package/package.json +55 -0
- package/scripts/smoke.sh +104 -0
- package/src/cli.js +348 -0
- package/src/client/editor.js +260 -0
- package/src/lib/backup.js +75 -0
- package/src/lib/check.js +124 -0
- package/src/lib/converters.js +586 -0
- package/src/lib/diff.js +69 -0
- package/src/lib/editor-assets.js +59 -0
- package/src/lib/i18n.js +175 -0
- package/src/lib/installer.js +187 -0
- package/src/lib/render.js +1181 -0
- package/src/lib/security.js +114 -0
- package/src/lib/server-state.js +53 -0
- package/src/lib/service.js +105 -0
- package/src/lib/storage.js +846 -0
- package/src/mcp-server.js +495 -0
- package/src/server.js +576 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { URL } from "node:url";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
archiveArtifact,
|
|
9
|
+
createArtifact,
|
|
10
|
+
createStore,
|
|
11
|
+
getArtifact,
|
|
12
|
+
listArtifacts,
|
|
13
|
+
listAuditEvents,
|
|
14
|
+
MAX_ARTIFACT_BYTES,
|
|
15
|
+
restoreArtifact,
|
|
16
|
+
updateArtifact
|
|
17
|
+
} from "./lib/storage.js";
|
|
18
|
+
import { convertAgentArtifact } from "./lib/converters.js";
|
|
19
|
+
import { createLineDiff } from "./lib/diff.js";
|
|
20
|
+
import { EDITOR_CLIENT_PATH, editorClientFilePath, editorVendorPath } from "./lib/editor-assets.js";
|
|
21
|
+
import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
|
|
22
|
+
import { requireToken, securityConfig, validateServerExposure } from "./lib/security.js";
|
|
23
|
+
import { writeServerState } from "./lib/server-state.js";
|
|
24
|
+
import {
|
|
25
|
+
renderArtifactFormPage,
|
|
26
|
+
renderArtifactPage,
|
|
27
|
+
renderDashboard,
|
|
28
|
+
renderDiffPage,
|
|
29
|
+
renderImportArtifactPage,
|
|
30
|
+
renderNewArtifactPage
|
|
31
|
+
} from "./lib/render.js";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
34
|
+
const DEFAULT_PORT = 8787;
|
|
35
|
+
const FALLBACK_PORT_ATTEMPTS = 10;
|
|
36
|
+
const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
37
|
+
|
|
38
|
+
export async function startServer(options = {}) {
|
|
39
|
+
const host = options.host || process.env.ARTIFACTY_HOST || DEFAULT_HOST;
|
|
40
|
+
const explicitPort = options.port !== undefined || process.env.ARTIFACTY_PORT !== undefined;
|
|
41
|
+
const requestedPort = Number(options.port ?? process.env.ARTIFACTY_PORT ?? DEFAULT_PORT);
|
|
42
|
+
const allowPortFallback =
|
|
43
|
+
options.portFallback === true ||
|
|
44
|
+
(!explicitPort && options.portFallback !== false);
|
|
45
|
+
const store = createStore({ home: options.home });
|
|
46
|
+
const security = securityConfig(options);
|
|
47
|
+
validateServerExposure({ host, config: security });
|
|
48
|
+
|
|
49
|
+
const { server, actualPort } = await listenWithFallback({
|
|
50
|
+
host,
|
|
51
|
+
requestedPort,
|
|
52
|
+
allowPortFallback,
|
|
53
|
+
createServer(port) {
|
|
54
|
+
const candidateServer = http.createServer((request, response) => {
|
|
55
|
+
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security }).catch((error) => {
|
|
56
|
+
sendError(response, error);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return candidateServer;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const url = `http://${host}:${actualPort}`;
|
|
64
|
+
const usedPortFallback = requestedPort !== 0 && actualPort !== requestedPort;
|
|
65
|
+
await writeServerState(store, {
|
|
66
|
+
url,
|
|
67
|
+
host,
|
|
68
|
+
port: actualPort,
|
|
69
|
+
requestedPort,
|
|
70
|
+
portFallback: usedPortFallback
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
server,
|
|
74
|
+
store,
|
|
75
|
+
url,
|
|
76
|
+
requestedPort,
|
|
77
|
+
port: actualPort,
|
|
78
|
+
portFallback: usedPortFallback,
|
|
79
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function listenWithFallback({ host, requestedPort, allowPortFallback, createServer }) {
|
|
84
|
+
const candidates = portCandidates(requestedPort, allowPortFallback);
|
|
85
|
+
let lastError;
|
|
86
|
+
|
|
87
|
+
for (const port of candidates) {
|
|
88
|
+
const server = createServer(port);
|
|
89
|
+
try {
|
|
90
|
+
await listenOnce(server, port, host);
|
|
91
|
+
return {
|
|
92
|
+
server,
|
|
93
|
+
actualPort: server.address().port
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
lastError = error;
|
|
97
|
+
await closeServer(server);
|
|
98
|
+
if (!allowPortFallback || error.code !== "EADDRINUSE") {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw lastError;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function portCandidates(port, allowPortFallback) {
|
|
108
|
+
if (!allowPortFallback || port === 0) {
|
|
109
|
+
return [port];
|
|
110
|
+
}
|
|
111
|
+
return [
|
|
112
|
+
port,
|
|
113
|
+
...Array.from({ length: FALLBACK_PORT_ATTEMPTS }, (_, index) => port + index + 1),
|
|
114
|
+
0
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listenOnce(server, port, host) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
server.once("error", reject);
|
|
121
|
+
server.listen(port, host, () => {
|
|
122
|
+
server.off("error", reject);
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function closeServer(server) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
if (!server.listening) {
|
|
131
|
+
resolve();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
server.close(() => resolve());
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function handleRequest({ request, response, store, host, port, security = securityConfig() }) {
|
|
139
|
+
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
140
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
141
|
+
const baseUrl = `http://${host}:${port}`;
|
|
142
|
+
const authToken = url.searchParams.get("token") || "";
|
|
143
|
+
const locale = localeFromUrl(url);
|
|
144
|
+
const currentPath = `${url.pathname}${url.search}`;
|
|
145
|
+
const headOnly = request.method === "HEAD";
|
|
146
|
+
const method = headOnly ? "GET" : request.method;
|
|
147
|
+
|
|
148
|
+
if (method === "GET" && pathname === EDITOR_CLIENT_PATH) {
|
|
149
|
+
return sendJavaScriptFile(response, editorClientFilePath(PACKAGE_ROOT), headOnly);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (method === "GET" && pathname.startsWith("/vendor/npm/")) {
|
|
153
|
+
const packageName = pathname.slice("/vendor/npm/".length);
|
|
154
|
+
const vendorPath = editorVendorPath(packageName, PACKAGE_ROOT);
|
|
155
|
+
if (!vendorPath) {
|
|
156
|
+
return sendJson(response, { error: "Not found" }, 404, headOnly);
|
|
157
|
+
}
|
|
158
|
+
return sendJavaScriptFile(response, vendorPath, headOnly);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (pathname.startsWith("/api/")) {
|
|
162
|
+
requireToken({ request, url, config: security });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (method === "GET" && pathname === "/health") {
|
|
166
|
+
return sendJson(response, { ok: true, name: "artifacty", store: store.home }, 200, headOnly);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (method === "GET" && pathname === "/") {
|
|
170
|
+
const filters = {
|
|
171
|
+
query: url.searchParams.get("q") || "",
|
|
172
|
+
tag: url.searchParams.get("tag") || "",
|
|
173
|
+
sourceAgent: url.searchParams.get("sourceAgent") || "",
|
|
174
|
+
includeArchived: url.searchParams.get("includeArchived") === "true"
|
|
175
|
+
};
|
|
176
|
+
const artifacts = await listArtifacts(store, {
|
|
177
|
+
query: filters.query || undefined,
|
|
178
|
+
tag: filters.tag || undefined,
|
|
179
|
+
sourceAgent: filters.sourceAgent || undefined,
|
|
180
|
+
includeArchived: filters.includeArchived
|
|
181
|
+
});
|
|
182
|
+
return sendHtml(response, renderDashboard({ artifacts, baseUrl, filters, locale, currentPath }), 200, headOnly);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (method === "GET" && pathname === "/new") {
|
|
186
|
+
return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (method === "POST" && pathname === "/new") {
|
|
190
|
+
assertLocalOrigin(request);
|
|
191
|
+
const body = await readFormBody(request);
|
|
192
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
193
|
+
requireToken({ request, url, body, config: security });
|
|
194
|
+
const artifact = await createArtifact(store, {
|
|
195
|
+
title: body.title,
|
|
196
|
+
content: body.content,
|
|
197
|
+
format: body.format,
|
|
198
|
+
artifactType: body.artifactType,
|
|
199
|
+
sourceAgent: body.sourceAgent || "artifacty",
|
|
200
|
+
tags: splitTags(body.tags),
|
|
201
|
+
metadata: {
|
|
202
|
+
createdVia: "artifacty-web"
|
|
203
|
+
},
|
|
204
|
+
audit: auditContext(request, "web")
|
|
205
|
+
});
|
|
206
|
+
return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (method === "GET" && pathname === "/import") {
|
|
210
|
+
return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (method === "POST" && pathname === "/import") {
|
|
214
|
+
assertLocalOrigin(request);
|
|
215
|
+
const body = await readFormBody(request);
|
|
216
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
217
|
+
requireToken({ request, url, body, config: security });
|
|
218
|
+
const converted = convertAgentArtifact({
|
|
219
|
+
agent: body.agent,
|
|
220
|
+
title: body.title,
|
|
221
|
+
content: body.content,
|
|
222
|
+
fileName: body.fileName,
|
|
223
|
+
tags: splitTags(body.tags),
|
|
224
|
+
metadata: {
|
|
225
|
+
createdVia: "artifacty-web-import"
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
const artifact = await createArtifact(store, {
|
|
229
|
+
...converted,
|
|
230
|
+
auditAction: "import",
|
|
231
|
+
audit: auditContext(request, "web")
|
|
232
|
+
});
|
|
233
|
+
return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (method === "GET" && pathname === "/api/artifacts") {
|
|
237
|
+
const artifacts = await listArtifacts(store, {
|
|
238
|
+
query: url.searchParams.get("q") || undefined,
|
|
239
|
+
tag: url.searchParams.get("tag") || undefined,
|
|
240
|
+
sourceAgent: url.searchParams.get("sourceAgent") || undefined,
|
|
241
|
+
includeArchived: url.searchParams.get("includeArchived") === "true",
|
|
242
|
+
limit: url.searchParams.get("limit") || undefined
|
|
243
|
+
});
|
|
244
|
+
return sendJson(response, { artifacts }, 200, headOnly);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (method === "GET" && pathname === "/api/audit") {
|
|
248
|
+
const events = await listAuditEvents(store, {
|
|
249
|
+
artifactId: url.searchParams.get("artifactId") || undefined,
|
|
250
|
+
limit: url.searchParams.get("limit") || undefined
|
|
251
|
+
});
|
|
252
|
+
return sendJson(response, { events }, 200, headOnly);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (method === "POST" && pathname === "/api/artifacts") {
|
|
256
|
+
assertLocalOrigin(request);
|
|
257
|
+
const body = await readJsonBody(request);
|
|
258
|
+
const artifact = await createArtifact(store, {
|
|
259
|
+
...body,
|
|
260
|
+
audit: auditContext(request, "http-api")
|
|
261
|
+
});
|
|
262
|
+
return sendJson(response, decorateArtifactUrls(artifact, baseUrl), 201);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (method === "POST" && pathname === "/api/import") {
|
|
266
|
+
assertLocalOrigin(request);
|
|
267
|
+
const body = await readJsonBody(request);
|
|
268
|
+
const converted = convertAgentArtifact(body);
|
|
269
|
+
const artifact = await createArtifact(store, {
|
|
270
|
+
...converted,
|
|
271
|
+
auditAction: "import",
|
|
272
|
+
audit: auditContext(request, "http-api")
|
|
273
|
+
});
|
|
274
|
+
return sendJson(response, {
|
|
275
|
+
...decorateArtifactUrls(artifact, baseUrl),
|
|
276
|
+
converted
|
|
277
|
+
}, 201);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const editMatch = /^\/artifacts\/([^/]+)\/edit$/.exec(pathname);
|
|
281
|
+
if (editMatch && method === "GET") {
|
|
282
|
+
const artifact = await getArtifact(store, editMatch[1], {
|
|
283
|
+
version: url.searchParams.get("version") || undefined
|
|
284
|
+
});
|
|
285
|
+
return sendHtml(response, renderArtifactFormPage({
|
|
286
|
+
mode: "edit",
|
|
287
|
+
baseUrl,
|
|
288
|
+
artifact,
|
|
289
|
+
version: artifact.version,
|
|
290
|
+
content: artifact.content,
|
|
291
|
+
authToken,
|
|
292
|
+
locale,
|
|
293
|
+
currentPath
|
|
294
|
+
}), 200, headOnly);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (editMatch && method === "POST") {
|
|
298
|
+
assertLocalOrigin(request);
|
|
299
|
+
const body = await readFormBody(request);
|
|
300
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
301
|
+
requireToken({ request, url, body, config: security });
|
|
302
|
+
const artifact = await updateArtifact(store, editMatch[1], {
|
|
303
|
+
title: body.title,
|
|
304
|
+
content: body.content,
|
|
305
|
+
format: body.format,
|
|
306
|
+
artifactType: body.artifactType,
|
|
307
|
+
sourceAgent: body.sourceAgent || "artifacty",
|
|
308
|
+
tags: splitTags(body.tags),
|
|
309
|
+
metadata: {
|
|
310
|
+
updatedVia: "artifacty-web"
|
|
311
|
+
},
|
|
312
|
+
audit: auditContext(request, "web")
|
|
313
|
+
});
|
|
314
|
+
return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const diffMatch = /^\/artifacts\/([^/]+)\/diff$/.exec(pathname);
|
|
318
|
+
if (diffMatch && method === "GET") {
|
|
319
|
+
const latest = await getArtifact(store, diffMatch[1]);
|
|
320
|
+
const defaultFrom = Math.max(1, latest.latestVersion - 1);
|
|
321
|
+
const fromNumber = Number(url.searchParams.get("from") || defaultFrom);
|
|
322
|
+
const toNumber = Number(url.searchParams.get("to") || latest.latestVersion);
|
|
323
|
+
const from = await getArtifact(store, diffMatch[1], { version: fromNumber });
|
|
324
|
+
const to = await getArtifact(store, diffMatch[1], { version: toNumber });
|
|
325
|
+
return sendHtml(response, renderDiffPage({
|
|
326
|
+
artifact: latest,
|
|
327
|
+
fromVersion: from.version,
|
|
328
|
+
toVersion: to.version,
|
|
329
|
+
fromContent: from.content,
|
|
330
|
+
toContent: to.content,
|
|
331
|
+
diffRows: createLineDiff(from.content, to.content),
|
|
332
|
+
baseUrl,
|
|
333
|
+
authToken,
|
|
334
|
+
locale,
|
|
335
|
+
currentPath
|
|
336
|
+
}), 200, headOnly);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const archiveMatch = /^\/artifacts\/([^/]+)\/(archive|restore)$/.exec(pathname);
|
|
340
|
+
if (archiveMatch && method === "POST") {
|
|
341
|
+
assertLocalOrigin(request);
|
|
342
|
+
const body = await readFormBody(request);
|
|
343
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
344
|
+
requireToken({ request, url, body, config: security });
|
|
345
|
+
const artifact = archiveMatch[2] === "archive"
|
|
346
|
+
? await archiveArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") })
|
|
347
|
+
: await restoreArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") });
|
|
348
|
+
return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const apiMatch = /^\/api\/artifacts\/([^/]+)$/.exec(pathname);
|
|
352
|
+
if (apiMatch && method === "GET") {
|
|
353
|
+
const artifact = await getArtifact(store, apiMatch[1], {
|
|
354
|
+
version: url.searchParams.get("version") || undefined
|
|
355
|
+
});
|
|
356
|
+
return sendJson(response, decorateArtifactUrls(artifact, baseUrl), 200, headOnly);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (apiMatch && method === "POST") {
|
|
360
|
+
assertLocalOrigin(request);
|
|
361
|
+
const body = await readJsonBody(request);
|
|
362
|
+
const artifact = await updateArtifact(store, apiMatch[1], {
|
|
363
|
+
...body,
|
|
364
|
+
audit: auditContext(request, "http-api")
|
|
365
|
+
});
|
|
366
|
+
return sendJson(response, decorateArtifactUrls(artifact, baseUrl));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const apiArchiveMatch = /^\/api\/artifacts\/([^/]+)\/(archive|restore)$/.exec(pathname);
|
|
370
|
+
if (apiArchiveMatch && method === "POST") {
|
|
371
|
+
assertLocalOrigin(request);
|
|
372
|
+
const artifact = apiArchiveMatch[2] === "archive"
|
|
373
|
+
? await archiveArtifact(store, apiArchiveMatch[1], { audit: auditContext(request, "http-api") })
|
|
374
|
+
: await restoreArtifact(store, apiArchiveMatch[1], { audit: auditContext(request, "http-api") });
|
|
375
|
+
return sendJson(response, decorateArtifactUrls(artifact, baseUrl));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const artifactMatch = /^\/artifacts\/([^/]+)(?:\/raw)?$/.exec(pathname);
|
|
379
|
+
if (artifactMatch && method === "GET") {
|
|
380
|
+
const artifact = await getArtifact(store, artifactMatch[1], {
|
|
381
|
+
version: url.searchParams.get("version") || undefined,
|
|
382
|
+
audit: auditContext(request, "browser")
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (pathname.endsWith("/raw")) {
|
|
386
|
+
response.writeHead(200, {
|
|
387
|
+
"content-type": artifact.version.contentType,
|
|
388
|
+
"cache-control": "no-store",
|
|
389
|
+
"x-content-type-options": "nosniff"
|
|
390
|
+
});
|
|
391
|
+
response.end(headOnly ? undefined : artifact.content);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return sendHtml(response, renderArtifactPage({
|
|
396
|
+
artifact,
|
|
397
|
+
version: artifact.version,
|
|
398
|
+
content: artifact.content,
|
|
399
|
+
baseUrl,
|
|
400
|
+
authToken,
|
|
401
|
+
locale,
|
|
402
|
+
currentPath
|
|
403
|
+
}), 200, headOnly);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
sendJson(response, { error: "Not found" }, 404);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function decorateArtifactUrls(artifact, baseUrl) {
|
|
410
|
+
return {
|
|
411
|
+
...artifact,
|
|
412
|
+
url: `${baseUrl}/artifacts/${encodeURIComponent(artifact.id)}`,
|
|
413
|
+
rawUrl: `${baseUrl}/artifacts/${encodeURIComponent(artifact.id)}/raw?version=${artifact.version.version}`
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function readJsonBody(request) {
|
|
418
|
+
const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
|
|
419
|
+
if (!raw.trim()) {
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(raw);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
throw Object.assign(new Error(`Invalid JSON body: ${error.message}`), {
|
|
426
|
+
statusCode: 400,
|
|
427
|
+
code: "INVALID_JSON"
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function readFormBody(request) {
|
|
433
|
+
const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
|
|
434
|
+
const params = new URLSearchParams(raw);
|
|
435
|
+
return Object.fromEntries(params.entries());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function readBody(request, limitBytes) {
|
|
439
|
+
const chunks = [];
|
|
440
|
+
let size = 0;
|
|
441
|
+
for await (const chunk of request) {
|
|
442
|
+
size += chunk.byteLength;
|
|
443
|
+
if (size > limitBytes) {
|
|
444
|
+
throw Object.assign(new Error("Request body too large"), {
|
|
445
|
+
statusCode: 413,
|
|
446
|
+
code: "REQUEST_TOO_LARGE"
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
chunks.push(chunk);
|
|
450
|
+
}
|
|
451
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function assertLocalOrigin(request) {
|
|
455
|
+
const origin = request.headers.origin;
|
|
456
|
+
if (!origin) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const parsed = new URL(origin);
|
|
461
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
462
|
+
const allowed = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
463
|
+
if (!allowed) {
|
|
464
|
+
throw Object.assign(new Error(`Rejected non-local origin: ${origin}`), {
|
|
465
|
+
statusCode: 403,
|
|
466
|
+
code: "NON_LOCAL_ORIGIN"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function sendJson(response, data, statusCode = 200, headOnly = false) {
|
|
472
|
+
response.writeHead(statusCode, {
|
|
473
|
+
"content-type": "application/json; charset=utf-8",
|
|
474
|
+
"cache-control": "no-store",
|
|
475
|
+
"x-content-type-options": "nosniff"
|
|
476
|
+
});
|
|
477
|
+
response.end(headOnly ? undefined : `${JSON.stringify(data, null, 2)}\n`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function sendHtml(response, html, statusCode = 200, headOnly = false) {
|
|
481
|
+
response.writeHead(statusCode, {
|
|
482
|
+
"content-type": "text/html; charset=utf-8",
|
|
483
|
+
"cache-control": "no-store",
|
|
484
|
+
"x-content-type-options": "nosniff",
|
|
485
|
+
"content-security-policy": [
|
|
486
|
+
"default-src 'self' data: blob:",
|
|
487
|
+
"frame-src 'self' data: blob:",
|
|
488
|
+
"img-src 'self' data: blob:",
|
|
489
|
+
"style-src 'self' 'unsafe-inline'",
|
|
490
|
+
"script-src 'self' 'unsafe-inline'",
|
|
491
|
+
"object-src 'none'",
|
|
492
|
+
"base-uri 'none'",
|
|
493
|
+
"form-action 'self'"
|
|
494
|
+
].join("; ")
|
|
495
|
+
});
|
|
496
|
+
response.end(headOnly ? undefined : html);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function sendJavaScriptFile(response, filePath, headOnly = false) {
|
|
500
|
+
const content = headOnly ? "" : await readFile(filePath, "utf8");
|
|
501
|
+
response.writeHead(200, {
|
|
502
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
503
|
+
"cache-control": "no-store",
|
|
504
|
+
"x-content-type-options": "nosniff"
|
|
505
|
+
});
|
|
506
|
+
response.end(headOnly ? undefined : content);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function sendRedirect(response, location) {
|
|
510
|
+
response.writeHead(303, {
|
|
511
|
+
location,
|
|
512
|
+
"cache-control": "no-store"
|
|
513
|
+
});
|
|
514
|
+
response.end();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function sendError(response, error) {
|
|
518
|
+
const statusCode = error.statusCode || 500;
|
|
519
|
+
const body = {
|
|
520
|
+
error: error.message,
|
|
521
|
+
code: error.code || "SERVER_ERROR"
|
|
522
|
+
};
|
|
523
|
+
if (error.findings) {
|
|
524
|
+
body.findings = error.findings;
|
|
525
|
+
}
|
|
526
|
+
sendJson(response, body, statusCode);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function splitTags(value) {
|
|
530
|
+
if (!value) {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
return String(value)
|
|
534
|
+
.split(",")
|
|
535
|
+
.map((tag) => tag.trim())
|
|
536
|
+
.filter(Boolean);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function auditContext(request, surface) {
|
|
540
|
+
return {
|
|
541
|
+
surface,
|
|
542
|
+
actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown"
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isMain(metaUrl) {
|
|
547
|
+
return process.argv[1] && metaUrl === new URL(`file://${process.argv[1]}`).href;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (isMain(import.meta.url)) {
|
|
551
|
+
const options = parseServerArgs(process.argv.slice(2));
|
|
552
|
+
startServer(options)
|
|
553
|
+
.then(({ url, store }) => {
|
|
554
|
+
process.stderr.write(`Artifacty listening on ${url}\n`);
|
|
555
|
+
process.stderr.write(`Store: ${store.home}\n`);
|
|
556
|
+
})
|
|
557
|
+
.catch((error) => {
|
|
558
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
559
|
+
process.exitCode = 1;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function parseServerArgs(args) {
|
|
564
|
+
const options = {};
|
|
565
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
566
|
+
const arg = args[index];
|
|
567
|
+
if (arg === "--host") {
|
|
568
|
+
options.host = args[++index];
|
|
569
|
+
} else if (arg === "--port") {
|
|
570
|
+
options.port = Number(args[++index]);
|
|
571
|
+
} else if (arg === "--home") {
|
|
572
|
+
options.home = args[++index];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return options;
|
|
576
|
+
}
|