artifacty 0.14.0 → 0.16.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/README.ko.md +54 -8
- package/README.md +56 -10
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/docs/README.md +6 -0
- package/docs/ko/README.md +6 -0
- package/docs/ko/language-packs.md +40 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/ko/upgrade-backups.md +16 -0
- package/docs/language-packs.md +38 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -0
- package/docs/upgrade-backups.md +16 -0
- package/package.json +10 -1
- package/scripts/build-ui.mjs +16 -0
- package/src/cli.js +4 -4
- package/src/client/api-docs.js +32 -0
- package/src/client/auto-apply.js +20 -0
- package/src/client/editor.js +75 -20
- package/src/client/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/update.js +33 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/background.js +3 -1
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +601 -9
- package/src/lib/language-settings.js +114 -0
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +888 -628
- package/src/lib/sample-content.js +6 -6
- package/src/lib/self-update.js +12 -0
- package/src/lib/smtp-client.js +206 -0
- package/src/lib/smtp-settings.js +109 -0
- package/src/lib/storage.js +71 -17
- package/src/lib/ui-icons.js +32 -0
- package/src/lib/web-update.js +177 -0
- package/src/mcp-server.js +3 -3
- package/src/server.js +250 -18
- package/src/styles/ui-controls.scss +63 -0
- package/src/update-runner.js +7 -0
package/src/server.js
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
setArtifactVisibility,
|
|
71
71
|
setReviewStatus,
|
|
72
72
|
setUserActive,
|
|
73
|
+
setUserRole,
|
|
74
|
+
resetUserPassword,
|
|
73
75
|
updateArtifact,
|
|
74
76
|
VersionConflictError,
|
|
75
77
|
verifyUserPassword
|
|
@@ -77,13 +79,17 @@ import {
|
|
|
77
79
|
import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNetworkPolicy, confirmNetworkPolicy, rollbackNetworkPolicy } from "./lib/network-policy.js";
|
|
78
80
|
import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
|
|
79
81
|
import { installSamples, removeSamples } from "./lib/samples.js";
|
|
82
|
+
import { createWebUpdater } from "./lib/web-update.js";
|
|
83
|
+
import { getLanguageSettings, saveLanguageSettings, importLanguagePack } from "./lib/language-settings.js";
|
|
84
|
+
import { getSmtpSettings, getSmtpDeliveryConfig, saveSmtpSettings } from "./lib/smtp-settings.js";
|
|
85
|
+
import { sendSmtpMail } from "./lib/smtp-client.js";
|
|
80
86
|
import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
|
|
81
87
|
import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
|
|
82
88
|
import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
|
|
83
89
|
import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
|
|
84
90
|
import { createEmbeddingProvider } from "./lib/embeddings.js";
|
|
85
91
|
import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
|
|
86
|
-
import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
|
|
92
|
+
import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
|
|
87
93
|
import { paginationJson } from "./lib/listing.js";
|
|
88
94
|
import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
|
|
89
95
|
import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
|
|
@@ -111,6 +117,10 @@ import {
|
|
|
111
117
|
renderAccountPage,
|
|
112
118
|
renderAdminBackupPage,
|
|
113
119
|
renderAdminAccessPage,
|
|
120
|
+
renderAccessModePreviewPage,
|
|
121
|
+
renderAdminUpdatePage,
|
|
122
|
+
renderAdminLanguagesPage,
|
|
123
|
+
renderAdminEmailPage,
|
|
114
124
|
renderArtifactSharingPage,
|
|
115
125
|
renderSharedArtifactPage,
|
|
116
126
|
renderInboxPage,
|
|
@@ -189,6 +199,7 @@ export async function startServer(options = {}) {
|
|
|
189
199
|
options.portFallback === true ||
|
|
190
200
|
(!explicitPort && options.portFallback !== false);
|
|
191
201
|
const store = createStore({ home: options.home });
|
|
202
|
+
const webUpdater = options.webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
|
|
192
203
|
const security = securityConfig(options);
|
|
193
204
|
const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
|
|
194
205
|
validateServerExposure({ host, config: security });
|
|
@@ -202,7 +213,7 @@ export async function startServer(options = {}) {
|
|
|
202
213
|
allowPortFallback,
|
|
203
214
|
createServer(port) {
|
|
204
215
|
const candidateServer = http.createServer((request, response) => {
|
|
205
|
-
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp }).catch((error) => {
|
|
216
|
+
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
|
|
206
217
|
sendError(response, error);
|
|
207
218
|
});
|
|
208
219
|
});
|
|
@@ -307,17 +318,67 @@ function closeServer(server) {
|
|
|
307
318
|
});
|
|
308
319
|
}
|
|
309
320
|
|
|
310
|
-
|
|
321
|
+
function isBrowserPath(pathname) {
|
|
322
|
+
return !/^\/(?:api(?:\/|$)|mcp(?:\/|$)|assets\/|vendor\/|health$|events$|openapi\.json$)/.test(pathname)
|
|
323
|
+
&& !/\/(?:raw|react-frame|export)$/.test(pathname);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function handleRequest(args) {
|
|
327
|
+
const { request, store } = args;
|
|
311
328
|
await assertNetworkAccess(store, request.socket.remoteAddress);
|
|
329
|
+
const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
|
|
330
|
+
if (!isBrowserPath(pathname)) return handleScopedRequest(args);
|
|
331
|
+
const settings = await getLanguageSettings(store);
|
|
332
|
+
return runWithLanguageSettings(settings, () => handleScopedRequest(args));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail }) {
|
|
312
336
|
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
313
337
|
const pathname = decodeURIComponent(url.pathname);
|
|
314
338
|
const baseUrl = `http://${host}:${port}`;
|
|
315
339
|
const authToken = url.searchParams.get("token") || "";
|
|
316
|
-
const locale = localeFromUrl(url);
|
|
317
340
|
const currentPath = `${url.pathname}${url.search}`;
|
|
341
|
+
const browserPage = isBrowserPath(pathname);
|
|
342
|
+
let locale = localeFromUrl(url);
|
|
343
|
+
if (browserPage) {
|
|
344
|
+
const explicit = url.searchParams.get("lang");
|
|
345
|
+
const cookie = parseCookies(request.headers.cookie).artifacty_locale;
|
|
346
|
+
const enabled = enabledLocales();
|
|
347
|
+
locale = enabled.includes(explicit) ? explicit
|
|
348
|
+
: enabled.includes(cookie) ? cookie : defaultLocale();
|
|
349
|
+
// Only this request-local URL changes. Keep the original path for links,
|
|
350
|
+
// and leave API and artifact bytes independent of browser preferences.
|
|
351
|
+
url.searchParams.set("lang", locale);
|
|
352
|
+
response.setHeader("cache-control", "no-store");
|
|
353
|
+
response.setHeader("vary", "Cookie");
|
|
354
|
+
if (enabled.includes(explicit)) {
|
|
355
|
+
response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
318
358
|
const headOnly = request.method === "HEAD";
|
|
319
359
|
const method = headOnly ? "GET" : request.method;
|
|
320
360
|
|
|
361
|
+
if (method === "GET" && pathname === "/assets/update.js") {
|
|
362
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
|
|
363
|
+
}
|
|
364
|
+
if (method === "GET" && pathname === "/assets/theme.js") {
|
|
365
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
|
|
366
|
+
}
|
|
367
|
+
if (method === "GET" && pathname === "/assets/auto-apply.js") {
|
|
368
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
|
|
369
|
+
}
|
|
370
|
+
if (method === "GET" && pathname === "/assets/sidebar.js") {
|
|
371
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
|
|
372
|
+
}
|
|
373
|
+
if (method === "GET" && pathname === "/assets/ui-controls.css") {
|
|
374
|
+
const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
|
|
375
|
+
response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
376
|
+
return response.end(headOnly ? undefined : content);
|
|
377
|
+
}
|
|
378
|
+
if (method === "GET" && pathname === "/assets/api-docs.js") {
|
|
379
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
|
|
380
|
+
}
|
|
381
|
+
|
|
321
382
|
const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
|
|
322
383
|
if (publicShareMatch && method === "GET") {
|
|
323
384
|
const shared = await resolveShareLink(store, publicShareMatch[1]);
|
|
@@ -375,7 +436,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
375
436
|
}
|
|
376
437
|
|
|
377
438
|
if (method === "GET" && pathname === "/docs/api") {
|
|
378
|
-
return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
|
|
439
|
+
return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
|
|
379
440
|
}
|
|
380
441
|
|
|
381
442
|
const userCount = await countUsers(store);
|
|
@@ -396,6 +457,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
396
457
|
if (method === "POST" && pathname === "/login") {
|
|
397
458
|
await enforceRateLimit({ store, host, request, bucket: "auth" });
|
|
398
459
|
const body = await readFormBody(request);
|
|
460
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
399
461
|
const email = body.email;
|
|
400
462
|
const password = body.password;
|
|
401
463
|
const user = userCount === 0
|
|
@@ -410,13 +472,13 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
410
472
|
return sendHtml(response, renderLoginPage({
|
|
411
473
|
baseUrl,
|
|
412
474
|
setup: false,
|
|
413
|
-
error: "
|
|
414
|
-
locale,
|
|
475
|
+
error: createI18n(bodyLocale).t("auth.invalidCredentials"),
|
|
476
|
+
locale: bodyLocale,
|
|
415
477
|
currentPath
|
|
416
478
|
}), 401);
|
|
417
479
|
}
|
|
418
480
|
const session = await createSession(store, user.id);
|
|
419
|
-
return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
|
|
481
|
+
return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
|
|
420
482
|
"set-cookie": sessionCookie(session.token)
|
|
421
483
|
});
|
|
422
484
|
}
|
|
@@ -446,13 +508,14 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
446
508
|
return sendRedirect(response, "/login");
|
|
447
509
|
}
|
|
448
510
|
const body = await readFormBody(request);
|
|
511
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
449
512
|
if (body.newPassword !== body.confirmPassword) {
|
|
450
513
|
return sendHtml(response, renderPasswordPage({
|
|
451
514
|
baseUrl,
|
|
452
515
|
user: currentUser,
|
|
453
516
|
required: currentUser.passwordResetRequired,
|
|
454
|
-
error: "
|
|
455
|
-
locale,
|
|
517
|
+
error: createI18n(bodyLocale).t("password.mismatch"),
|
|
518
|
+
locale: bodyLocale,
|
|
456
519
|
currentPath
|
|
457
520
|
}), 400);
|
|
458
521
|
}
|
|
@@ -464,8 +527,8 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
464
527
|
return sendHtml(response, renderPasswordPage({
|
|
465
528
|
baseUrl,
|
|
466
529
|
user: updatedUser,
|
|
467
|
-
success: "
|
|
468
|
-
locale,
|
|
530
|
+
success: createI18n(bodyLocale).t("password.changed"),
|
|
531
|
+
locale: bodyLocale,
|
|
469
532
|
currentPath
|
|
470
533
|
}));
|
|
471
534
|
} catch (error) {
|
|
@@ -546,6 +609,93 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
546
609
|
}
|
|
547
610
|
|
|
548
611
|
const browserAccess = accessContext(request, currentUser);
|
|
612
|
+
if (pathname === "/admin/email") {
|
|
613
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
614
|
+
requireAdmin(currentUser);
|
|
615
|
+
const emailPage = async (extra = {}) => renderAdminEmailPage({
|
|
616
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
|
|
617
|
+
});
|
|
618
|
+
if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
|
|
619
|
+
if (method === "POST") {
|
|
620
|
+
assertSameOrigin(request);
|
|
621
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
622
|
+
const body = await readFormBody(request, 16 * 1024);
|
|
623
|
+
try {
|
|
624
|
+
await saveSmtpSettings(store, {
|
|
625
|
+
enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
|
|
626
|
+
tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
|
|
627
|
+
}, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
|
|
628
|
+
return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (error.statusCode !== 400) throw error;
|
|
631
|
+
return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
635
|
+
}
|
|
636
|
+
if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
|
|
637
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
638
|
+
requireAdmin(currentUser);
|
|
639
|
+
if (method === "GET" && pathname === "/admin/languages/template") {
|
|
640
|
+
response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
|
|
641
|
+
return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
|
|
642
|
+
}
|
|
643
|
+
const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
|
|
644
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
|
|
645
|
+
settings: await getLanguageSettings(store), ...extra
|
|
646
|
+
});
|
|
647
|
+
if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
|
|
648
|
+
if (method === "POST" && pathname !== "/admin/languages/template") {
|
|
649
|
+
assertSameOrigin(request);
|
|
650
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
651
|
+
try {
|
|
652
|
+
// URL encoding can use three bytes for each byte of the JSON text.
|
|
653
|
+
const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
|
|
654
|
+
const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
|
|
655
|
+
if (pathname.endsWith("/import")) {
|
|
656
|
+
if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
|
|
657
|
+
throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
|
|
658
|
+
}
|
|
659
|
+
let pack;
|
|
660
|
+
try { pack = JSON.parse(body.pack); }
|
|
661
|
+
catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
|
|
662
|
+
await importLanguagePack(store, pack, options);
|
|
663
|
+
} else {
|
|
664
|
+
await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
|
|
665
|
+
}
|
|
666
|
+
return sendRedirect(response, "/admin/languages");
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (![400, 413].includes(error.statusCode)) throw error;
|
|
669
|
+
return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
673
|
+
}
|
|
674
|
+
if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
675
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
676
|
+
requireAdmin(currentUser);
|
|
677
|
+
response.setHeader("cache-control", "no-store");
|
|
678
|
+
const updater = webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
|
|
679
|
+
const session = sessionTokenFromRequest(request);
|
|
680
|
+
if (method === "GET" && pathname === "/admin/update/status") return sendJson(response, await updater.status(session), 200, headOnly);
|
|
681
|
+
if (method === "GET" && pathname === "/admin/update") {
|
|
682
|
+
return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale, currentPath, status: await updater.status(session) }), 200, headOnly);
|
|
683
|
+
}
|
|
684
|
+
if (method === "POST" && ["/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
685
|
+
assertLocalOrigin(request);
|
|
686
|
+
const expectedOrigin = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
|
|
687
|
+
if (request.headers.origin !== expectedOrigin) throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "UPDATE_ORIGIN_REQUIRED" });
|
|
688
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
689
|
+
const body = await readFormBody(request);
|
|
690
|
+
if (pathname.endsWith("/check")) {
|
|
691
|
+
const status = await updater.check(session);
|
|
692
|
+
return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/update", status }));
|
|
693
|
+
}
|
|
694
|
+
await updater.start({ session, confirmationId: body.confirmationId, targetVersion: body.targetVersion, confirmUpdate: body.confirmUpdate });
|
|
695
|
+
return sendRedirect(response, localizedHref("/admin/update", localeFromBodyOrUrl(body, url)));
|
|
696
|
+
}
|
|
697
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
698
|
+
}
|
|
549
699
|
const browserAudit = { surface: "web", actor: currentUser?.email, userId: currentUser?.id };
|
|
550
700
|
const adminAccessPage = async (extra = {}) => renderAdminAccessPage({
|
|
551
701
|
baseUrl, user: currentUser, mode: await getAccessMode(store),
|
|
@@ -559,16 +709,23 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
559
709
|
if (!currentUser) return sendRedirect(response, "/login");
|
|
560
710
|
requireAdmin(currentUser);
|
|
561
711
|
if (method === "GET" && pathname === "/admin/access") {
|
|
562
|
-
|
|
712
|
+
const selectedMode = url.searchParams.get("mode");
|
|
713
|
+
const preview = ["public", "private"].includes(selectedMode)
|
|
714
|
+
? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
|
|
715
|
+
return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
|
|
563
716
|
}
|
|
564
717
|
if (method === "POST") {
|
|
565
718
|
assertLocalOrigin(request);
|
|
719
|
+
if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
|
|
566
720
|
request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
|
|
567
721
|
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
568
722
|
const body = await readFormBody(request);
|
|
569
723
|
const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
|
|
570
724
|
if (pathname === "/admin/access/preview") {
|
|
571
|
-
return sendHtml(response,
|
|
725
|
+
return sendHtml(response, renderAccessModePreviewPage({
|
|
726
|
+
baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
|
|
727
|
+
...await previewAccessMode(store, body.mode, options)
|
|
728
|
+
}), 200, headOnly);
|
|
572
729
|
} else if (pathname === "/admin/access") {
|
|
573
730
|
await setAccessMode(store, body.mode, options);
|
|
574
731
|
} else if (pathname === "/admin/access/network") {
|
|
@@ -779,6 +936,67 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
779
936
|
}
|
|
780
937
|
}
|
|
781
938
|
|
|
939
|
+
const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
|
|
940
|
+
if (userPasswordResetMatch && method === "POST") {
|
|
941
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
942
|
+
requireAdmin(currentUser);
|
|
943
|
+
assertSameOrigin(request);
|
|
944
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
945
|
+
const body = await readFormBody(request);
|
|
946
|
+
const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
|
|
947
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
948
|
+
});
|
|
949
|
+
resetResult.mailStatus = "not-configured";
|
|
950
|
+
try {
|
|
951
|
+
const smtp = await getSmtpDeliveryConfig(store);
|
|
952
|
+
if (smtp.enabled) {
|
|
953
|
+
const messages = createI18n(localeFromBodyOrUrl(body, url));
|
|
954
|
+
await smtpSender(smtp, {
|
|
955
|
+
to: resetResult.user.email,
|
|
956
|
+
subject: messages.t("smtp.resetSubject"),
|
|
957
|
+
text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
|
|
958
|
+
});
|
|
959
|
+
resetResult.mailStatus = "sent";
|
|
960
|
+
}
|
|
961
|
+
} catch {
|
|
962
|
+
// Reset already succeeded. Keep the one-time password available for
|
|
963
|
+
// manual delivery, including when SMTP acceptance is uncertain.
|
|
964
|
+
resetResult.mailStatus = "failed";
|
|
965
|
+
}
|
|
966
|
+
response.setHeader("cache-control", "no-store");
|
|
967
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
968
|
+
baseUrl, user: currentUser, users: await listUsers(store), resetResult,
|
|
969
|
+
locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
|
|
974
|
+
if (userRoleMatch && method === "POST") {
|
|
975
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
976
|
+
requireAdmin(currentUser);
|
|
977
|
+
assertLocalOrigin(request);
|
|
978
|
+
if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
|
|
979
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
|
|
980
|
+
}
|
|
981
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
982
|
+
const body = await readFormBody(request);
|
|
983
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
984
|
+
try {
|
|
985
|
+
await setUserRole(store, userRoleMatch[1], body.role, {
|
|
986
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
987
|
+
});
|
|
988
|
+
const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
|
|
989
|
+
return sendRedirect(response, localizedHref(destination, bodyLocale));
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (![400, 409].includes(error.statusCode)) throw error;
|
|
992
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
993
|
+
baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
|
|
994
|
+
roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
|
|
995
|
+
: error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
|
|
996
|
+
}), error.statusCode);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
782
1000
|
const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
|
|
783
1001
|
if (userActiveMatch && method === "POST") {
|
|
784
1002
|
if (!currentUser) {
|
|
@@ -1048,7 +1266,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1048
1266
|
}
|
|
1049
1267
|
|
|
1050
1268
|
if (method === "GET" && pathname === "/new") {
|
|
1051
|
-
return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
1269
|
+
return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
|
|
1052
1270
|
}
|
|
1053
1271
|
|
|
1054
1272
|
if (method === "POST" && pathname === "/new") {
|
|
@@ -1075,7 +1293,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1075
1293
|
}
|
|
1076
1294
|
|
|
1077
1295
|
if (method === "GET" && pathname === "/import") {
|
|
1078
|
-
return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
|
|
1296
|
+
return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
|
|
1079
1297
|
}
|
|
1080
1298
|
|
|
1081
1299
|
if (method === "POST" && pathname === "/import") {
|
|
@@ -1342,6 +1560,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1342
1560
|
access: accessContext(request, currentUser)
|
|
1343
1561
|
});
|
|
1344
1562
|
return sendHtml(response, renderArtifactFormPage({
|
|
1563
|
+
user: currentUser,
|
|
1345
1564
|
mode: "edit",
|
|
1346
1565
|
baseUrl,
|
|
1347
1566
|
artifact,
|
|
@@ -1388,6 +1607,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1388
1607
|
tags: splitTags(body.tags)
|
|
1389
1608
|
};
|
|
1390
1609
|
return sendHtml(response, renderArtifactFormPage({
|
|
1610
|
+
user: currentUser,
|
|
1391
1611
|
mode: "edit",
|
|
1392
1612
|
baseUrl,
|
|
1393
1613
|
artifact: unsavedArtifact,
|
|
@@ -1419,6 +1639,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
1419
1639
|
? createStructuredDiff(from.content, to.content, { format: diffFormat })
|
|
1420
1640
|
: null;
|
|
1421
1641
|
return sendHtml(response, renderDiffPage({
|
|
1642
|
+
user: currentUser,
|
|
1422
1643
|
artifact: latest,
|
|
1423
1644
|
fromVersion: from.version,
|
|
1424
1645
|
toVersion: to.version,
|
|
@@ -1897,7 +2118,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
|
|
|
1897
2118
|
const params = new URLSearchParams(raw);
|
|
1898
2119
|
const body = Object.fromEntries(params.entries());
|
|
1899
2120
|
// Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
|
|
1900
|
-
for (const key of ["userIds", "teamIds"]) {
|
|
2121
|
+
for (const key of ["userIds", "teamIds", "enabledLocales"]) {
|
|
1901
2122
|
if (params.has(key)) body[key] = params.getAll(key);
|
|
1902
2123
|
}
|
|
1903
2124
|
return body;
|
|
@@ -1919,6 +2140,13 @@ export async function readBody(request, limitBytes) {
|
|
|
1919
2140
|
return Buffer.concat(chunks).toString("utf8");
|
|
1920
2141
|
}
|
|
1921
2142
|
|
|
2143
|
+
function assertSameOrigin(request) {
|
|
2144
|
+
const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
|
|
2145
|
+
if (request.headers.origin !== expected) {
|
|
2146
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1922
2150
|
export function assertLocalOrigin(request) {
|
|
1923
2151
|
const origin = request.headers.origin;
|
|
1924
2152
|
if (!origin) {
|
|
@@ -2165,6 +2393,10 @@ function javascriptCorsHeaders(request) {
|
|
|
2165
2393
|
}
|
|
2166
2394
|
|
|
2167
2395
|
export function sendRedirect(response, location, headers = {}) {
|
|
2396
|
+
const existingCookies = response.getHeader("set-cookie");
|
|
2397
|
+
if (existingCookies && headers["set-cookie"]) {
|
|
2398
|
+
headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
|
|
2399
|
+
}
|
|
2168
2400
|
response.writeHead(303, {
|
|
2169
2401
|
location,
|
|
2170
2402
|
"cache-control": "no-store",
|
|
@@ -2741,7 +2973,7 @@ function parseCookies(header) {
|
|
|
2741
2973
|
const key = part.slice(0, index).trim();
|
|
2742
2974
|
const value = part.slice(index + 1).trim();
|
|
2743
2975
|
if (key) {
|
|
2744
|
-
cookies[key] = decodeURIComponent(value);
|
|
2976
|
+
try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
|
|
2745
2977
|
}
|
|
2746
2978
|
}
|
|
2747
2979
|
return cookies;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Artifacty control layer: official Bootstrap components only; no Reboot or layout reset.
|
|
2
|
+
@import "bootstrap/scss/functions";
|
|
3
|
+
$primary: #315ca8;
|
|
4
|
+
$secondary: #58616e;
|
|
5
|
+
$danger: #b42332;
|
|
6
|
+
$font-family-sans-serif: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
|
7
|
+
$font-size-base: .875rem;
|
|
8
|
+
$border-radius: .375rem;
|
|
9
|
+
$input-btn-font-size: .8125rem;
|
|
10
|
+
$input-btn-padding-y: .4375rem;
|
|
11
|
+
$input-btn-padding-x: .75rem;
|
|
12
|
+
$btn-font-weight: 500;
|
|
13
|
+
$btn-white-space: nowrap;
|
|
14
|
+
$input-btn-focus-width: .1875rem;
|
|
15
|
+
$input-btn-focus-color-opacity: .2;
|
|
16
|
+
$enable-shadows: false;
|
|
17
|
+
@import "bootstrap/scss/variables";
|
|
18
|
+
@import "bootstrap/scss/variables-dark";
|
|
19
|
+
@import "bootstrap/scss/maps";
|
|
20
|
+
@import "bootstrap/scss/mixins";
|
|
21
|
+
@import "bootstrap/scss/root";
|
|
22
|
+
@import "bootstrap/scss/forms";
|
|
23
|
+
@import "bootstrap/scss/buttons";
|
|
24
|
+
@import "bootstrap/scss/button-group";
|
|
25
|
+
|
|
26
|
+
:root, [data-bs-theme="dark"] {
|
|
27
|
+
--bs-body-font-family: #{$font-family-sans-serif};
|
|
28
|
+
--bs-body-color: var(--text, #20242b);
|
|
29
|
+
--bs-body-bg: var(--panel, #fff);
|
|
30
|
+
--bs-secondary-color: var(--muted, #58616e);
|
|
31
|
+
--bs-border-color: var(--line-2, #b9c1cc);
|
|
32
|
+
--bs-secondary-bg: var(--panel-2, #eef0f3);
|
|
33
|
+
}
|
|
34
|
+
.btn { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; gap: .375rem; }
|
|
35
|
+
.btn[hidden] { display: none !important; }
|
|
36
|
+
.form-control, .form-select { min-height: 38px; font-family: var(--bs-body-font-family); }
|
|
37
|
+
.form-control::placeholder { color: var(--muted, #58616e); opacity: .85; }
|
|
38
|
+
.form-control:focus, .form-select:focus { border-color: var(--accent, #315ca8); }
|
|
39
|
+
.btn-outline-secondary, .btn-ghost {
|
|
40
|
+
--bs-btn-color: var(--text, #20242b);
|
|
41
|
+
--bs-btn-border-color: var(--line, #dfe3e8);
|
|
42
|
+
--bs-btn-hover-color: var(--text, #20242b);
|
|
43
|
+
--bs-btn-hover-bg: var(--panel-2, #eef0f3);
|
|
44
|
+
--bs-btn-hover-border-color: var(--line-2, #b9c1cc);
|
|
45
|
+
--bs-btn-active-color: var(--text, #20242b);
|
|
46
|
+
--bs-btn-active-bg: var(--panel-2, #eef0f3);
|
|
47
|
+
--bs-btn-active-border-color: var(--line-2, #b9c1cc);
|
|
48
|
+
--bs-btn-disabled-color: var(--muted, #58616e);
|
|
49
|
+
--bs-btn-disabled-border-color: var(--line, #dfe3e8);
|
|
50
|
+
--bs-btn-focus-shadow-rgb: 49, 92, 168;
|
|
51
|
+
}
|
|
52
|
+
.btn-ghost { --bs-btn-border-color: transparent; }
|
|
53
|
+
[data-bs-theme="dark"] .btn-outline-danger {
|
|
54
|
+
--bs-btn-color: #ff9ba5;
|
|
55
|
+
--bs-btn-border-color: #73404a;
|
|
56
|
+
--bs-btn-hover-bg: #93313e;
|
|
57
|
+
--bs-btn-hover-border-color: #93313e;
|
|
58
|
+
}
|
|
59
|
+
.btn:focus-visible { outline: 2px solid var(--ring, #315ca8); outline-offset: 2px; }
|
|
60
|
+
@media (pointer: coarse) {
|
|
61
|
+
.btn, .form-control, .form-select { min-height: 44px; }
|
|
62
|
+
}
|
|
63
|
+
form[data-auto-apply] [hidden] { display: none !important; }
|