artifacty 0.15.0 → 0.16.1
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 +50 -8
- package/README.md +51 -9
- 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/network-sharing.md +17 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/language-packs.md +38 -0
- package/docs/network-sharing.md +18 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -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/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +501 -11
- package/src/lib/language-settings.js +114 -0
- package/src/lib/network-policy.js +7 -6
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +553 -397
- 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/mcp-server.js +3 -3
- package/src/server.js +224 -19
- package/src/styles/ui-controls.scss +63 -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
|
|
@@ -78,13 +80,16 @@ import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNe
|
|
|
78
80
|
import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
|
|
79
81
|
import { installSamples, removeSamples } from "./lib/samples.js";
|
|
80
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";
|
|
81
86
|
import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
|
|
82
87
|
import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
|
|
83
88
|
import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
|
|
84
89
|
import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
|
|
85
90
|
import { createEmbeddingProvider } from "./lib/embeddings.js";
|
|
86
91
|
import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
|
|
87
|
-
import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
|
|
92
|
+
import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
|
|
88
93
|
import { paginationJson } from "./lib/listing.js";
|
|
89
94
|
import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
|
|
90
95
|
import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
|
|
@@ -112,7 +117,10 @@ import {
|
|
|
112
117
|
renderAccountPage,
|
|
113
118
|
renderAdminBackupPage,
|
|
114
119
|
renderAdminAccessPage,
|
|
120
|
+
renderAccessModePreviewPage,
|
|
115
121
|
renderAdminUpdatePage,
|
|
122
|
+
renderAdminLanguagesPage,
|
|
123
|
+
renderAdminEmailPage,
|
|
116
124
|
renderArtifactSharingPage,
|
|
117
125
|
renderSharedArtifactPage,
|
|
118
126
|
renderInboxPage,
|
|
@@ -205,7 +213,7 @@ export async function startServer(options = {}) {
|
|
|
205
213
|
allowPortFallback,
|
|
206
214
|
createServer(port) {
|
|
207
215
|
const candidateServer = http.createServer((request, response) => {
|
|
208
|
-
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater }).catch((error) => {
|
|
216
|
+
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
|
|
209
217
|
sendError(response, error);
|
|
210
218
|
});
|
|
211
219
|
});
|
|
@@ -310,24 +318,78 @@ function closeServer(server) {
|
|
|
310
318
|
});
|
|
311
319
|
}
|
|
312
320
|
|
|
313
|
-
|
|
314
|
-
|
|
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;
|
|
328
|
+
let networkSharedArtifact;
|
|
329
|
+
try { assertNetworkAccess(store, request.socket.remoteAddress); }
|
|
330
|
+
catch (cause) {
|
|
331
|
+
const match = /^\/s\/([A-Za-z0-9_-]{43})(?:\/raw)?$/.exec(new URL(request.url, "http://artifacty.local").pathname);
|
|
332
|
+
if (cause.code !== "IP_NOT_ALLOWED" || !["GET", "HEAD"].includes(request.method) || !match || !getNetworkPolicy(store).allowSharedLinks) throw cause;
|
|
333
|
+
// Authenticate the capability before admitting an otherwise blocked peer.
|
|
334
|
+
networkSharedArtifact = await resolveShareLink(store, match[1]);
|
|
335
|
+
}
|
|
336
|
+
args = { ...args, networkSharedArtifact };
|
|
337
|
+
const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
|
|
338
|
+
if (!isBrowserPath(pathname)) return handleScopedRequest(args);
|
|
339
|
+
const settings = await getLanguageSettings(store);
|
|
340
|
+
return runWithLanguageSettings(settings, () => handleScopedRequest(args));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail, networkSharedArtifact }) {
|
|
315
344
|
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
316
345
|
const pathname = decodeURIComponent(url.pathname);
|
|
317
346
|
const baseUrl = `http://${host}:${port}`;
|
|
318
347
|
const authToken = url.searchParams.get("token") || "";
|
|
319
|
-
const locale = localeFromUrl(url);
|
|
320
348
|
const currentPath = `${url.pathname}${url.search}`;
|
|
349
|
+
const browserPage = isBrowserPath(pathname);
|
|
350
|
+
let locale = localeFromUrl(url);
|
|
351
|
+
if (browserPage) {
|
|
352
|
+
const explicit = url.searchParams.get("lang");
|
|
353
|
+
const cookie = parseCookies(request.headers.cookie).artifacty_locale;
|
|
354
|
+
const enabled = enabledLocales();
|
|
355
|
+
locale = enabled.includes(explicit) ? explicit
|
|
356
|
+
: enabled.includes(cookie) ? cookie : defaultLocale();
|
|
357
|
+
// Only this request-local URL changes. Keep the original path for links,
|
|
358
|
+
// and leave API and artifact bytes independent of browser preferences.
|
|
359
|
+
url.searchParams.set("lang", locale);
|
|
360
|
+
response.setHeader("cache-control", "no-store");
|
|
361
|
+
response.setHeader("vary", "Cookie");
|
|
362
|
+
if (enabled.includes(explicit)) {
|
|
363
|
+
response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
321
366
|
const headOnly = request.method === "HEAD";
|
|
322
367
|
const method = headOnly ? "GET" : request.method;
|
|
323
368
|
|
|
324
369
|
if (method === "GET" && pathname === "/assets/update.js") {
|
|
325
370
|
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
|
|
326
371
|
}
|
|
372
|
+
if (method === "GET" && pathname === "/assets/theme.js") {
|
|
373
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
|
|
374
|
+
}
|
|
375
|
+
if (method === "GET" && pathname === "/assets/auto-apply.js") {
|
|
376
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
|
|
377
|
+
}
|
|
378
|
+
if (method === "GET" && pathname === "/assets/sidebar.js") {
|
|
379
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
|
|
380
|
+
}
|
|
381
|
+
if (method === "GET" && pathname === "/assets/ui-controls.css") {
|
|
382
|
+
const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
|
|
383
|
+
response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
384
|
+
return response.end(headOnly ? undefined : content);
|
|
385
|
+
}
|
|
386
|
+
if (method === "GET" && pathname === "/assets/api-docs.js") {
|
|
387
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
|
|
388
|
+
}
|
|
327
389
|
|
|
328
390
|
const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
|
|
329
391
|
if (publicShareMatch && method === "GET") {
|
|
330
|
-
const shared = await resolveShareLink(store, publicShareMatch[1]);
|
|
392
|
+
const shared = networkSharedArtifact || await resolveShareLink(store, publicShareMatch[1]);
|
|
331
393
|
response.setHeader("referrer-policy", "no-referrer");
|
|
332
394
|
response.setHeader("cache-control", "no-store");
|
|
333
395
|
if (publicShareMatch[2]) {
|
|
@@ -382,7 +444,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
382
444
|
}
|
|
383
445
|
|
|
384
446
|
if (method === "GET" && pathname === "/docs/api") {
|
|
385
|
-
return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
|
|
447
|
+
return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
|
|
386
448
|
}
|
|
387
449
|
|
|
388
450
|
const userCount = await countUsers(store);
|
|
@@ -403,6 +465,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
403
465
|
if (method === "POST" && pathname === "/login") {
|
|
404
466
|
await enforceRateLimit({ store, host, request, bucket: "auth" });
|
|
405
467
|
const body = await readFormBody(request);
|
|
468
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
406
469
|
const email = body.email;
|
|
407
470
|
const password = body.password;
|
|
408
471
|
const user = userCount === 0
|
|
@@ -417,13 +480,13 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
417
480
|
return sendHtml(response, renderLoginPage({
|
|
418
481
|
baseUrl,
|
|
419
482
|
setup: false,
|
|
420
|
-
error: "
|
|
421
|
-
locale,
|
|
483
|
+
error: createI18n(bodyLocale).t("auth.invalidCredentials"),
|
|
484
|
+
locale: bodyLocale,
|
|
422
485
|
currentPath
|
|
423
486
|
}), 401);
|
|
424
487
|
}
|
|
425
488
|
const session = await createSession(store, user.id);
|
|
426
|
-
return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
|
|
489
|
+
return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
|
|
427
490
|
"set-cookie": sessionCookie(session.token)
|
|
428
491
|
});
|
|
429
492
|
}
|
|
@@ -453,13 +516,14 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
453
516
|
return sendRedirect(response, "/login");
|
|
454
517
|
}
|
|
455
518
|
const body = await readFormBody(request);
|
|
519
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
456
520
|
if (body.newPassword !== body.confirmPassword) {
|
|
457
521
|
return sendHtml(response, renderPasswordPage({
|
|
458
522
|
baseUrl,
|
|
459
523
|
user: currentUser,
|
|
460
524
|
required: currentUser.passwordResetRequired,
|
|
461
|
-
error: "
|
|
462
|
-
locale,
|
|
525
|
+
error: createI18n(bodyLocale).t("password.mismatch"),
|
|
526
|
+
locale: bodyLocale,
|
|
463
527
|
currentPath
|
|
464
528
|
}), 400);
|
|
465
529
|
}
|
|
@@ -471,8 +535,8 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
471
535
|
return sendHtml(response, renderPasswordPage({
|
|
472
536
|
baseUrl,
|
|
473
537
|
user: updatedUser,
|
|
474
|
-
success: "
|
|
475
|
-
locale,
|
|
538
|
+
success: createI18n(bodyLocale).t("password.changed"),
|
|
539
|
+
locale: bodyLocale,
|
|
476
540
|
currentPath
|
|
477
541
|
}));
|
|
478
542
|
} catch (error) {
|
|
@@ -553,6 +617,68 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
553
617
|
}
|
|
554
618
|
|
|
555
619
|
const browserAccess = accessContext(request, currentUser);
|
|
620
|
+
if (pathname === "/admin/email") {
|
|
621
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
622
|
+
requireAdmin(currentUser);
|
|
623
|
+
const emailPage = async (extra = {}) => renderAdminEmailPage({
|
|
624
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
|
|
625
|
+
});
|
|
626
|
+
if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
|
|
627
|
+
if (method === "POST") {
|
|
628
|
+
assertSameOrigin(request);
|
|
629
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
630
|
+
const body = await readFormBody(request, 16 * 1024);
|
|
631
|
+
try {
|
|
632
|
+
await saveSmtpSettings(store, {
|
|
633
|
+
enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
|
|
634
|
+
tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
|
|
635
|
+
}, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
|
|
636
|
+
return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (error.statusCode !== 400) throw error;
|
|
639
|
+
return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
643
|
+
}
|
|
644
|
+
if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
|
|
645
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
646
|
+
requireAdmin(currentUser);
|
|
647
|
+
if (method === "GET" && pathname === "/admin/languages/template") {
|
|
648
|
+
response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
|
|
649
|
+
return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
|
|
650
|
+
}
|
|
651
|
+
const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
|
|
652
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
|
|
653
|
+
settings: await getLanguageSettings(store), ...extra
|
|
654
|
+
});
|
|
655
|
+
if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
|
|
656
|
+
if (method === "POST" && pathname !== "/admin/languages/template") {
|
|
657
|
+
assertSameOrigin(request);
|
|
658
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
659
|
+
try {
|
|
660
|
+
// URL encoding can use three bytes for each byte of the JSON text.
|
|
661
|
+
const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
|
|
662
|
+
const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
|
|
663
|
+
if (pathname.endsWith("/import")) {
|
|
664
|
+
if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
|
|
665
|
+
throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
|
|
666
|
+
}
|
|
667
|
+
let pack;
|
|
668
|
+
try { pack = JSON.parse(body.pack); }
|
|
669
|
+
catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
|
|
670
|
+
await importLanguagePack(store, pack, options);
|
|
671
|
+
} else {
|
|
672
|
+
await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
|
|
673
|
+
}
|
|
674
|
+
return sendRedirect(response, "/admin/languages");
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (![400, 413].includes(error.statusCode)) throw error;
|
|
677
|
+
return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
681
|
+
}
|
|
556
682
|
if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
557
683
|
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
558
684
|
requireAdmin(currentUser);
|
|
@@ -591,20 +717,27 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
591
717
|
if (!currentUser) return sendRedirect(response, "/login");
|
|
592
718
|
requireAdmin(currentUser);
|
|
593
719
|
if (method === "GET" && pathname === "/admin/access") {
|
|
594
|
-
|
|
720
|
+
const selectedMode = url.searchParams.get("mode");
|
|
721
|
+
const preview = ["public", "private"].includes(selectedMode)
|
|
722
|
+
? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
|
|
723
|
+
return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
|
|
595
724
|
}
|
|
596
725
|
if (method === "POST") {
|
|
597
726
|
assertLocalOrigin(request);
|
|
727
|
+
if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
|
|
598
728
|
request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
|
|
599
729
|
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
600
730
|
const body = await readFormBody(request);
|
|
601
731
|
const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
|
|
602
732
|
if (pathname === "/admin/access/preview") {
|
|
603
|
-
return sendHtml(response,
|
|
733
|
+
return sendHtml(response, renderAccessModePreviewPage({
|
|
734
|
+
baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
|
|
735
|
+
...await previewAccessMode(store, body.mode, options)
|
|
736
|
+
}), 200, headOnly);
|
|
604
737
|
} else if (pathname === "/admin/access") {
|
|
605
738
|
await setAccessMode(store, body.mode, options);
|
|
606
739
|
} else if (pathname === "/admin/access/network") {
|
|
607
|
-
await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "" }, options);
|
|
740
|
+
await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "", allowSharedLinks: body.allowSharedLinks === "true" || body.allowSharedLinks === "on" }, options);
|
|
608
741
|
} else if (pathname === "/admin/access/network/confirm") {
|
|
609
742
|
await confirmNetworkPolicy(store, body.id, options);
|
|
610
743
|
} else if (pathname === "/admin/access/network/rollback") {
|
|
@@ -811,6 +944,67 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
811
944
|
}
|
|
812
945
|
}
|
|
813
946
|
|
|
947
|
+
const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
|
|
948
|
+
if (userPasswordResetMatch && method === "POST") {
|
|
949
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
950
|
+
requireAdmin(currentUser);
|
|
951
|
+
assertSameOrigin(request);
|
|
952
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
953
|
+
const body = await readFormBody(request);
|
|
954
|
+
const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
|
|
955
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
956
|
+
});
|
|
957
|
+
resetResult.mailStatus = "not-configured";
|
|
958
|
+
try {
|
|
959
|
+
const smtp = await getSmtpDeliveryConfig(store);
|
|
960
|
+
if (smtp.enabled) {
|
|
961
|
+
const messages = createI18n(localeFromBodyOrUrl(body, url));
|
|
962
|
+
await smtpSender(smtp, {
|
|
963
|
+
to: resetResult.user.email,
|
|
964
|
+
subject: messages.t("smtp.resetSubject"),
|
|
965
|
+
text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
|
|
966
|
+
});
|
|
967
|
+
resetResult.mailStatus = "sent";
|
|
968
|
+
}
|
|
969
|
+
} catch {
|
|
970
|
+
// Reset already succeeded. Keep the one-time password available for
|
|
971
|
+
// manual delivery, including when SMTP acceptance is uncertain.
|
|
972
|
+
resetResult.mailStatus = "failed";
|
|
973
|
+
}
|
|
974
|
+
response.setHeader("cache-control", "no-store");
|
|
975
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
976
|
+
baseUrl, user: currentUser, users: await listUsers(store), resetResult,
|
|
977
|
+
locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
|
|
982
|
+
if (userRoleMatch && method === "POST") {
|
|
983
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
984
|
+
requireAdmin(currentUser);
|
|
985
|
+
assertLocalOrigin(request);
|
|
986
|
+
if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
|
|
987
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
|
|
988
|
+
}
|
|
989
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
990
|
+
const body = await readFormBody(request);
|
|
991
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
992
|
+
try {
|
|
993
|
+
await setUserRole(store, userRoleMatch[1], body.role, {
|
|
994
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
995
|
+
});
|
|
996
|
+
const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
|
|
997
|
+
return sendRedirect(response, localizedHref(destination, bodyLocale));
|
|
998
|
+
} catch (error) {
|
|
999
|
+
if (![400, 409].includes(error.statusCode)) throw error;
|
|
1000
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
1001
|
+
baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
|
|
1002
|
+
roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
|
|
1003
|
+
: error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
|
|
1004
|
+
}), error.statusCode);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
814
1008
|
const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
|
|
815
1009
|
if (userActiveMatch && method === "POST") {
|
|
816
1010
|
if (!currentUser) {
|
|
@@ -1932,7 +2126,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
|
|
|
1932
2126
|
const params = new URLSearchParams(raw);
|
|
1933
2127
|
const body = Object.fromEntries(params.entries());
|
|
1934
2128
|
// Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
|
|
1935
|
-
for (const key of ["userIds", "teamIds"]) {
|
|
2129
|
+
for (const key of ["userIds", "teamIds", "enabledLocales"]) {
|
|
1936
2130
|
if (params.has(key)) body[key] = params.getAll(key);
|
|
1937
2131
|
}
|
|
1938
2132
|
return body;
|
|
@@ -1954,6 +2148,13 @@ export async function readBody(request, limitBytes) {
|
|
|
1954
2148
|
return Buffer.concat(chunks).toString("utf8");
|
|
1955
2149
|
}
|
|
1956
2150
|
|
|
2151
|
+
function assertSameOrigin(request) {
|
|
2152
|
+
const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
|
|
2153
|
+
if (request.headers.origin !== expected) {
|
|
2154
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1957
2158
|
export function assertLocalOrigin(request) {
|
|
1958
2159
|
const origin = request.headers.origin;
|
|
1959
2160
|
if (!origin) {
|
|
@@ -2200,6 +2401,10 @@ function javascriptCorsHeaders(request) {
|
|
|
2200
2401
|
}
|
|
2201
2402
|
|
|
2202
2403
|
export function sendRedirect(response, location, headers = {}) {
|
|
2404
|
+
const existingCookies = response.getHeader("set-cookie");
|
|
2405
|
+
if (existingCookies && headers["set-cookie"]) {
|
|
2406
|
+
headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
|
|
2407
|
+
}
|
|
2203
2408
|
response.writeHead(303, {
|
|
2204
2409
|
location,
|
|
2205
2410
|
"cache-control": "no-store",
|
|
@@ -2776,7 +2981,7 @@ function parseCookies(header) {
|
|
|
2776
2981
|
const key = part.slice(0, index).trim();
|
|
2777
2982
|
const value = part.slice(index + 1).trim();
|
|
2778
2983
|
if (key) {
|
|
2779
|
-
cookies[key] = decodeURIComponent(value);
|
|
2984
|
+
try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
|
|
2780
2985
|
}
|
|
2781
2986
|
}
|
|
2782
2987
|
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; }
|