feat: add ability to enable catpcha

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 19:53:39 +02:00
1 parent 7d51458988
commit 6a6a87e38d
25 files changed
+811 -7

No files matched your search

+32
View File
@@ -7,6 +7,7 @@
"": {
"name": "thingport-backend",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcryptjs": "^3.0.3",
@@ -20,6 +21,7 @@
"nodemailer": "^10.0.1",
"prisma": "^5.20.0",
"sharp": "^0.35.4",
"svg-captcha": "^1.4.0",
"three": "^0.160.1",
"yauzl": "^3.2.0",
"zod": "^3.23.8"
@@ -3650,6 +3652,18 @@
"wrappy": "1"
}
},
"node_modules/opentype.js": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz",
"integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==",
"license": "MIT",
"dependencies": {
"tiny-inflate": "^1.0.2"
},
"bin": {
"ot": "bin/ot"
}
},
"node_modules/oxlint": {
"version": "1.81.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz",
@@ -4308,12 +4322,30 @@
"node": ">=6.6.0"
}
},
"node_modules/svg-captcha": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz",
"integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==",
"license": "MIT",
"dependencies": {
"opentype.js": "^0.7.3"
},
"engines": {
"node": ">=4.x"
}
},
"node_modules/three": {
"version": "0.160.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.1.tgz",
"integrity": "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==",
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"nodemailer": "^10.0.1",
"prisma": "^5.20.0",
"sharp": "^0.35.4",
"svg-captcha": "^1.4.0",
"three": "^0.160.1",
"yauzl": "^3.2.0",
"zod": "^3.23.8"
+2
View File
@@ -4,6 +4,7 @@ import { resolveCorsOrigins } from "./cors";
import { HttpError } from "./utils/fileUtils";
import healthRoutes from "./routes/health";
import captchaRoutes from "./routes/captcha";
import authRoutes from "./routes/auth";
import printsRoutes from "./routes/prints";
import authorsRoutes from "./routes/authors";
@@ -38,6 +39,7 @@ export function createApp(): Express {
// Mounted under /api because the frontend is served separately by its own nginx container,
// which reverse-proxies /api/* here unmodified (see frontend/nginx.conf).
app.use("/api", healthRoutes);
app.use("/api", captchaRoutes);
app.use("/api", authRoutes);
app.use("/api", settingsRoutes);
app.use("/api", printsRoutes);
+4
View File
@@ -10,6 +10,7 @@ import { asyncHandler } from "../utils/asyncHandler";
import { getAllowRegistrations, isSmtpConfigured } from "../services/settingsService";
import { sendVerificationEmail } from "../services/mailer";
import { findValidInvitation } from "../services/invitationService";
import { checkCaptcha } from "../services/captchaService";
import { createLog } from "../services/auditLog";
import { seedDefaultCategories } from "../services/categoryService";
import { toUserOut } from "../dto";
@@ -41,6 +42,7 @@ const loginSchema = z.object({
router.post(
"/register",
asyncHandler(async (req, res) => {
await checkCaptcha(req, "register");
const body = parseBody(registerSchema, req.body);
const email = body.email.toLowerCase();
@@ -119,6 +121,8 @@ router.post(
router.post(
"/login",
asyncHandler(async (req, res) => {
// Before the password check, so guessing passwords costs a solved captcha per attempt.
await checkCaptcha(req, "login");
const body = parseBody(loginSchema, req.body);
const email = body.email.toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
+28
View File
@@ -0,0 +1,28 @@
import { Router } from "express";
import { asyncHandler } from "../utils/asyncHandler";
import { createCaptcha } from "../services/captchaService";
import { getCaptchaSettings } from "../services/settingsService";
const router = Router();
// Public: the sign-in and register forms need one before anyone is logged in. Only the image and
// its id leave the server -- the answer stays in captchaService's memory.
router.get(
"/captcha",
asyncHandler(async (_req, res) => {
const { id, image } = createCaptcha();
res.set("Cache-Control", "no-store");
res.json({ id, image });
}),
);
// Where the web app has to ask for a captcha -- also public, for the same reason. Changed by admins
// through PATCH /settings/captcha.
router.get(
"/captcha/settings",
asyncHandler(async (_req, res) => {
res.json(await getCaptchaSettings());
}),
);
export default router;
+7
View File
@@ -7,6 +7,7 @@ import { HttpError } from "../utils/fileUtils";
import { normalizeImportUrl } from "../utils/urlUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { requireCaptcha } from "../services/captchaService";
import { checkImportStatus, downloadImportToTemp, findImportedExternalIds, importPrintFromUrl, inspectImportLink } from "../services/importService";
import { resolveMakerworldCookie } from "../services/importResolvers";
import { extractMakerworldBearerToken, MakerworldAuthError, MakerworldCaptchaError } from "../services/makerworldCloudApi";
@@ -68,6 +69,7 @@ async function withStoredMakerworldCookie<T extends { makerworld_cookie?: string
router.post(
"/import",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
const url = await normalizeImportUrl(body.url);
@@ -296,6 +298,7 @@ const collectionImportRequestSchema = importRequestSchema.extend({ design_ids: z
router.post(
"/import/collection",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = await withStoredMakerworldCookie(req.userId!, parseBody(collectionImportRequestSchema, req.body));
await assertNoActiveJob(req.userId!);
@@ -314,6 +317,7 @@ const thingiverseLikesImportRequestSchema = importRequestSchema.extend({ thing_i
router.post(
"/import/thingiverse-likes",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = parseBody(thingiverseLikesImportRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
@@ -332,6 +336,7 @@ router.post(
router.post(
"/import/thingiverse-collection",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = parseBody(thingiverseLikesImportRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
@@ -352,6 +357,7 @@ const printablesCollectionImportRequestSchema = importRequestSchema.extend({ mod
router.post(
"/import/printables-collection",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = parseBody(printablesCollectionImportRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
@@ -372,6 +378,7 @@ const zipExtractRequestSchema = importRequestSchema.extend({ entries: z.array(z.
router.post(
"/import/zip",
requireCaptcha("import"),
asyncHandler(async (req, res) => {
const body = parseBody(zipExtractRequestSchema, req.body);
await assertNoActiveJob(req.userId!);
+12
View File
@@ -6,6 +6,7 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import {
getAllowRegistrations,
setCaptchaSettings,
getAuthTokenTtl,
getPreviewMode,
getSmtpSettings,
@@ -95,6 +96,17 @@ router.get(
}),
);
// Administration > Captcha. Reading them is public and lives in routes/captcha.ts -- the sign-in
// and register forms need it before anyone is logged in.
const captchaSettingsSchema = z.object({ login: z.boolean(), register: z.boolean(), import: z.boolean() }).partial();
router.patch(
"/settings/captcha",
requireAdmin,
asyncHandler(async (req, res) => {
res.json(await setCaptchaSettings(parseBody(captchaSettingsSchema, req.body)));
}),
);
// Administration > Users. Closing registrations makes POST /register refuse everyone but
// invitees (see services/invitationService.ts) and the very first account.
const registrationsSchema = z.object({ allow_registrations: z.boolean() });
+93
View File
@@ -0,0 +1,93 @@
// Image captchas for login, registration and import (turned on per place in Administration >
// Captcha). Generated here, no outside service: svg-captcha draws each character as vector
// outlines -- not <text> -- with a slight tilt and a couple of noise lines, so the answer isn't
// sitting in the image's markup, yet it stays easy for a person to read.
//
// Answers live only in this process's memory: each captcha is single-use (checked answers are
// dropped, right or wrong) and expires after CAPTCHA_TTL_MS. A restart simply invalidates any
// captcha that was on screen -- the form fetches a new one.
import crypto from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import svgCaptcha from "svg-captcha";
import { HttpError } from "../utils/fileUtils";
import { isCaptchaEnabled, type CaptchaPlace } from "./settingsService";
const CAPTCHA_TTL_MS = 10 * 60 * 1000;
// Bounds memory if something requests captchas in a loop: the oldest pending ones go first.
const MAX_PENDING = 5000;
// Characters people mix up (0/O, 1/I/l) are left out -- the point is stopping scripts, not people.
const IGNORE_CHARS = "0oO1iIlL";
const pending = new Map<string, { answer: string; expiresAt: number }>();
function dropExpired(now: number): void {
for (const [id, entry] of pending) {
if (entry.expiresAt <= now) pending.delete(id);
}
}
/** A new captcha: its id, the image to show (as a data: URL), and the answer -- which callers
* other than tests must not send to the client. */
export function createCaptcha(): { id: string; image: string; answer: string } {
const now = Date.now();
dropExpired(now);
while (pending.size >= MAX_PENDING) pending.delete(pending.keys().next().value!);
const { data, text } = svgCaptcha.create({
size: 5,
ignoreChars: IGNORE_CHARS,
noise: 2,
color: true,
background: "#f4f6f8",
width: 160,
height: 56,
fontSize: 52,
});
const id = crypto.randomUUID();
pending.set(id, { answer: text, expiresAt: now + CAPTCHA_TTL_MS });
return { id, image: `data:image/svg+xml;base64,${Buffer.from(data).toString("base64")}`, answer: text };
}
/** Checks (and uses up) a captcha. Case-insensitive, surrounding spaces ignored. */
export function verifyCaptcha(id: unknown, answer: unknown): boolean {
if (typeof id !== "string" || typeof answer !== "string") return false;
const entry = pending.get(id);
pending.delete(id);
if (!entry || entry.expiresAt <= Date.now()) return false;
return entry.answer.toLowerCase() === answer.trim().toLowerCase();
}
// The Origin a browser puts on a request an extension's background script sends to another site
// (checked live for Chromium: a POST from an MV3 service worker carries chrome-extension://<id>).
const EXTENSION_ORIGIN = /^(chrome|moz|safari-web)-extension:\/\//i;
/** Thingport Grab (the browser extension) isn't asked for captchas -- it signs in and imports in the
* background, with nowhere to show one. It's recognized by its X-Thingport-Client header, or, for
* versions from before that header existed, by the extension Origin its browser attaches: every
* guarded endpoint is a POST, which always carries one. A page's own requests can't claim an
* extension Origin (the browser sets it), but anything outside a browser can send either, so this
* is a convenience for the extension, not a security boundary. */
export function isExtensionRequest(req: Request): boolean {
return req.get("x-thingport-client") === "grab" || EXTENSION_ORIGIN.test(req.get("origin") ?? "");
}
/** Throws unless `place` doesn't need a captcha right now, or the request body carries a correct
* one (`captcha_id` + `captcha_answer`). */
export async function checkCaptcha(req: Request, place: CaptchaPlace): Promise<void> {
if (isExtensionRequest(req) || !(await isCaptchaEnabled(place))) return;
const body = (req.body ?? {}) as { captcha_id?: unknown; captcha_answer?: unknown };
if (typeof body.captcha_answer !== "string" || !body.captcha_answer.trim()) {
throw new HttpError(400, "Enter the characters shown in the image.", "CAPTCHA_REQUIRED");
}
if (!verifyCaptcha(body.captcha_id, body.captcha_answer)) {
throw new HttpError(400, "The characters didn't match the image. Try the new one.", "CAPTCHA_INVALID");
}
}
/** Route middleware form of checkCaptcha, for endpoints guarded as a whole (the import starts). */
export function requireCaptcha(place: CaptchaPlace) {
return (req: Request, _res: Response, next: NextFunction) => {
checkCaptcha(req, place).then(() => next(), next);
};
}
+23
View File
@@ -17,6 +17,29 @@ async function setBoolSetting(key: string, value: boolean): Promise<void> {
});
}
export type CaptchaPlace = "login" | "register" | "import";
export type CaptchaSettings = Record<CaptchaPlace, boolean>;
export const CAPTCHA_PLACES: readonly CaptchaPlace[] = ["login", "register", "import"];
const captchaKey = (place: CaptchaPlace) => `captcha_${place}`;
// Instance-wide, admin-set (Administration > Captcha): where the web app asks for a captcha. All
// off by default -- see services/captchaService.ts.
export async function getCaptchaSettings(): Promise<CaptchaSettings> {
const values = await Promise.all(CAPTCHA_PLACES.map((place) => getBoolSetting(captchaKey(place), false)));
return Object.fromEntries(CAPTCHA_PLACES.map((place, i) => [place, values[i]])) as CaptchaSettings;
}
export async function isCaptchaEnabled(place: CaptchaPlace): Promise<boolean> {
return getBoolSetting(captchaKey(place), false);
}
export async function setCaptchaSettings(patch: Partial<CaptchaSettings>): Promise<CaptchaSettings> {
for (const place of CAPTCHA_PLACES) {
if (patch[place] !== undefined) await setBoolSetting(captchaKey(place), patch[place]);
}
return getCaptchaSettings();
}
const ALLOW_REGISTRATIONS_KEY = "allow_registrations";
export async function getAllowRegistrations(fallback: boolean): Promise<boolean> {
+192
View File
@@ -0,0 +1,192 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { createCaptcha } from "../src/services/captchaService";
import { getCaptchaSettings, setCaptchaSettings } from "../src/services/settingsService";
const app = createApp();
const stamp = Date.now();
const email = (name: string) => `${name}-${stamp}@example.com`;
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const ALL_OFF = { login: false, register: false, import: false };
let adminToken: string;
let memberToken: string;
// Shared database across test files (see vitest.config.ts) -- put the settings back afterwards.
let previousSettings: Awaited<ReturnType<typeof getCaptchaSettings>>;
/** A captcha whose answer the test knows -- the HTTP endpoint never reveals it. */
function solved(): { captcha_id: string; captcha_answer: string } {
const { id, answer } = createCaptcha();
return { captcha_id: id, captcha_answer: answer };
}
beforeAll(async () => {
previousSettings = await getCaptchaSettings();
await setCaptchaSettings(ALL_OFF);
const adminRegister = await request(app)
.post("/api/register")
.send({ displayName: "Captcha Admin", email: email("captcha-admin"), password: "password123" });
await prisma.user.update({ where: { id: adminRegister.body.user.id }, data: { role: "ADMIN" } });
adminToken = (await request(app).post("/api/login").send({ email: email("captcha-admin"), password: "password123" })).body.token;
const memberRegister = await request(app)
.post("/api/register")
.send({ displayName: "Captcha Member", email: email("captcha-member"), password: "password123" });
memberToken = memberRegister.body.token;
});
afterEach(async () => {
vi.useRealTimers();
await setCaptchaSettings(ALL_OFF);
});
afterAll(async () => {
await setCaptchaSettings(previousSettings);
});
describe("captcha settings", () => {
it("are all off by default and readable without signing in", async () => {
const res = await request(app).get("/api/captcha/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual(ALL_OFF);
});
it("can only be changed by an admin, one place at a time", async () => {
expect((await request(app).patch("/api/settings/captcha").set(auth(memberToken)).send({ login: true })).status).toBe(403);
const res = await request(app).patch("/api/settings/captcha").set(auth(adminToken)).send({ login: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ login: true, register: false, import: false });
});
});
describe("GET /api/captcha", () => {
it("returns an image and an id, but not the answer", async () => {
const res = await request(app).get("/api/captcha");
expect(res.status).toBe(200);
expect(Object.keys(res.body).toSorted()).toEqual(["id", "image"]);
expect(res.body.image).toMatch(/^data:image\/svg\+xml;base64,/);
const svg = Buffer.from(res.body.image.split(",")[1], "base64").toString("utf8");
// Characters are drawn as outlines, so there's no text for a script to read out of the SVG.
expect(svg).toContain("<path");
expect(svg).not.toContain("<text");
});
});
describe("captcha on login", () => {
const login = (extra: object = {}) =>
request(app).post("/api/login").send({ email: email("captcha-member"), password: "password123", ...extra });
it("isn't asked for while turned off", async () => {
expect((await login()).status).toBe(200);
});
it("is required once turned on, and must match", async () => {
await setCaptchaSettings({ login: true });
const missing = await login();
expect(missing.status).toBe(400);
expect(missing.body.code).toBe("CAPTCHA_REQUIRED");
const wrong = await login({ captcha_id: createCaptcha().id, captcha_answer: "wrong" });
expect(wrong.status).toBe(400);
expect(wrong.body.code).toBe("CAPTCHA_INVALID");
expect((await login(solved())).status).toBe(200);
});
it("is case-insensitive", async () => {
await setCaptchaSettings({ login: true });
const { captcha_id, captcha_answer } = solved();
const swapped = [...captcha_answer].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join("");
expect((await login({ captcha_id, captcha_answer: ` ${swapped} ` })).status).toBe(200);
});
it("works only once", async () => {
await setCaptchaSettings({ login: true });
const captcha = solved();
expect((await login(captcha)).status).toBe(200);
expect((await login(captcha)).status).toBe(400);
});
it("expires", async () => {
await setCaptchaSettings({ login: true });
vi.useFakeTimers({ toFake: ["Date"] });
const captcha = solved();
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
expect((await login(captcha)).body.code).toBe("CAPTCHA_INVALID");
});
it("is checked before the password, so a wrong password still costs a captcha", async () => {
await setCaptchaSettings({ login: true });
const res = await request(app).post("/api/login").send({ email: email("captcha-member"), password: "nope" });
expect(res.body.code).toBe("CAPTCHA_REQUIRED");
});
it("isn't asked of Thingport Grab", async () => {
await setCaptchaSettings({ login: true });
expect((await login().set("X-Thingport-Client", "grab")).status).toBe(200);
});
it("isn't asked of older Thingport Grab versions, recognized by their extension Origin", async () => {
await setCaptchaSettings({ login: true });
expect((await login().set("Origin", "chrome-extension://kahfidpmojfocohinlmglnfoaimocbol")).status).toBe(200);
expect((await login().set("Origin", "moz-extension://1b2c3d4e-0000-4000-8000-123456789abc")).status).toBe(200);
});
it("is still asked of the web app's own requests", async () => {
await setCaptchaSettings({ login: true });
expect((await login().set("Origin", "https://thingport.example.com")).body.code).toBe("CAPTCHA_REQUIRED");
});
});
describe("captcha on registration", () => {
const register = (name: string, extra: object = {}) =>
request(app).post("/api/register").send({ displayName: name, email: email(name), password: "password123", ...extra });
it("is required once turned on", async () => {
await setCaptchaSettings({ register: true });
expect((await register("reg-no-captcha")).body.code).toBe("CAPTCHA_REQUIRED");
expect(await prisma.user.findUnique({ where: { email: email("reg-no-captcha") } })).toBeNull();
expect((await register("reg-captcha", solved())).status).toBe(200);
});
it("doesn't affect login", async () => {
await setCaptchaSettings({ register: true });
expect((await request(app).post("/api/login").send({ email: email("captcha-member"), password: "password123" })).status).toBe(200);
});
});
describe("captcha on import", () => {
it("guards every endpoint that starts an import, but not the previews", async () => {
await setCaptchaSettings({ import: true });
const starts: [string, object][] = [
["/api/import", { url: "https://example.com/model.stl" }],
["/api/import/zip", { url: "https://example.com/models.zip", entries: ["a.stl"] }],
["/api/import/collection", { url: "https://makerworld.com/en/collections/1", design_ids: ["1"] }],
["/api/import/thingiverse-likes", { url: "https://www.thingiverse.com/someone/likes", thing_ids: ["1"] }],
["/api/import/thingiverse-collection", { url: "https://www.thingiverse.com/someone/collections/1", thing_ids: ["1"] }],
["/api/import/printables-collection", { url: "https://www.printables.com/@someone/collections/1", model_ids: ["1"] }],
];
const codes: Record<string, string> = {};
for (const [path, body] of starts) {
codes[path] = (await request(app).post(path).set(auth(memberToken)).send(body)).body.code;
}
expect(codes).toEqual(Object.fromEntries(starts.map(([path]) => [path, "CAPTCHA_REQUIRED"])));
// Import status is a read, not an import -- no captcha.
const status = await request(app).get("/api/import/status").query({ url: "https://example.com/model.stl" }).set(auth(memberToken));
expect(status.body.code).not.toBe("CAPTCHA_REQUIRED");
});
it("lets an import through with a solved captcha, or from Thingport Grab", async () => {
await setCaptchaSettings({ import: true });
// Nothing is listening at this address, so the import itself fails -- but past the captcha.
const body = { url: "http://127.0.0.1:9/model.stl" };
const captchaCodes = ["CAPTCHA_REQUIRED", "CAPTCHA_INVALID"];
const withCaptcha = await request(app).post("/api/import").set(auth(memberToken)).send({ ...body, ...solved() });
expect(captchaCodes).not.toContain(withCaptcha.body.code);
const fromGrab = await request(app).post("/api/import").set({ ...auth(memberToken), "X-Thingport-Client": "grab" }).send(body);
expect(captchaCodes).not.toContain(fromGrab.body.code);
});
});
+5
View File
@@ -27,6 +27,7 @@ import UsersPage from "./pages/UsersPage";
import LogsPage from "./pages/LogsPage";
import TriggersPage from "./pages/TriggersPage";
import ConnectionsPage from "./pages/ConnectionsPage";
import CaptchaPage from "./pages/CaptchaPage";
import { healthApi, type HealthInfo } from "./api/health";
import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
@@ -232,6 +233,10 @@ function AppShell({
path="/admin-connections"
element={isAdmin ? <ConnectionsPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route
path="/admin-captcha"
element={isAdmin ? <CaptchaPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
+12 -2
View File
@@ -1,5 +1,6 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, EmailNotVerifiedError, UnauthorizedError } from "./client";
import type { CaptchaAnswer } from "./captcha";
export type AuthUser = {
id: string;
@@ -49,15 +50,24 @@ async function postAuth<T>(path: string, body: unknown): Promise<T> {
}
export const authApi = {
login: (email: string, password: string): Promise<AuthResult> => postAuth("/login", { email, password }),
// `captcha` only when Administration > Captcha asks for one on login.
login: (email: string, password: string, captcha?: CaptchaAnswer | null): Promise<AuthResult> =>
postAuth("/login", { email, password, ...captcha }),
// `inviteToken` comes from an invitation link -- the only way in while registrations are closed.
register: (payload: { displayName: string; email: string; password: string; inviteToken?: string }): Promise<RegisterResult> =>
register: (payload: {
displayName: string;
email: string;
password: string;
inviteToken?: string;
captcha?: CaptchaAnswer | null;
}): Promise<RegisterResult> =>
postAuth("/register", {
displayName: payload.displayName,
email: payload.email,
password: payload.password,
...(payload.inviteToken ? { invite_token: payload.inviteToken } : {}),
...payload.captcha,
}),
/** Checks an invitation link before its form is filled in -- rejects if it's invalid or expired. */
+35
View File
@@ -0,0 +1,35 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
/** Where the web app asks for a captcha (Administration > Captcha). */
export type CaptchaPlace = "login" | "register" | "import";
export type CaptchaSettings = Record<CaptchaPlace, boolean>;
/** A solved captcha, sent along with the request it guards. */
export type CaptchaAnswer = { captcha_id: string; captcha_answer: string };
export const captchaApi = {
/** A fresh captcha image. Each one works once and expires after 10 minutes. */
get: async (): Promise<{ id: string; image: string }> => {
const res = await fetch(`${apiBase()}/captcha`, { cache: "no-store" });
assertOk(res, "Failed to load the captcha");
return res.json();
},
getSettings: async (): Promise<CaptchaSettings> => {
const res = await fetch(`${apiBase()}/captcha/settings`);
assertOk(res, "Failed to load captcha settings");
return res.json();
},
updateSettings: async (patch: Partial<CaptchaSettings>): Promise<CaptchaSettings> => {
const res = await fetch(`${apiBase()}/settings/captcha`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(patch),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update captcha settings"));
return res.json();
},
};
+4
View File
@@ -62,6 +62,10 @@ type ImportLinkPayload = {
category_id?: string;
filename?: string;
makerworld_cookie?: string;
// Solved captcha, when Administration > Captcha asks for one on import. Travels with the payload
// through every step, and is checked (and used up) by whichever request starts the import.
captcha_id?: string;
captcha_answer?: string;
};
/** What POST /import did -- "profile_added" when a MakerWorld model already in the library
+103
View File
@@ -0,0 +1,103 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import RefreshIcon from "@mui/icons-material/Refresh";
import { captchaApi, type CaptchaAnswer } from "../api/captcha";
type Props = {
onChange: (answer: CaptchaAnswer | null) => void;
disabled?: boolean;
};
/** A captcha image (generated by the backend, see services/captchaService.ts) with a field for
* its characters. Reports the id + typed answer through `onChange`, for the caller to send with
* the request the captcha guards. Each captcha works once, so after a failed attempt callers
* remount this with a new `key` to get a fresh image. */
export default function CaptchaField({ onChange, disabled = false }: Props) {
const { t } = useTranslation("app");
const [captcha, setCaptcha] = React.useState<{ id: string; image: string } | null>(null);
const [answer, setAnswer] = React.useState("");
const [failed, setFailed] = React.useState(false);
const onChangeRef = React.useRef(onChange);
// Only the newest request may update the image -- a slow earlier one mustn't replace it.
const requestRef = React.useRef(0);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const load = React.useCallback(() => {
const request = ++requestRef.current;
setCaptcha(null);
setAnswer("");
setFailed(false);
onChangeRef.current(null);
captchaApi.get().then(
(next) => { if (requestRef.current === request) setCaptcha(next); },
() => { if (requestRef.current === request) setFailed(true); },
);
}, []);
React.useEffect(() => {
load();
return () => { requestRef.current += 1; };
}, [load]);
const handleAnswer = (value: string) => {
setAnswer(value);
onChangeRef.current(captcha ? { captcha_id: captcha.id, captcha_answer: value } : null);
};
return (
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 160,
height: 56,
borderRadius: 1,
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{captcha ? (
<Box component="img" src={captcha.image} alt={t("captcha.imageAlt")} sx={{ width: 160, height: 56, display: "block" }} />
) : failed ? (
<Typography variant="caption" color="error" sx={{ px: 1, textAlign: "center" }}>{t("captcha.loadFailed")}</Typography>
) : (
<Skeleton variant="rectangular" width={160} height={56} />
)}
</Box>
<Tooltip title={t("captcha.newImage")}>
<span>
<IconButton onClick={load} disabled={disabled} aria-label={t("captcha.newImage")}>
<RefreshIcon />
</IconButton>
</span>
</Tooltip>
</Stack>
<TextField
label={t("captcha.label")}
value={answer}
onChange={(e) => handleAnswer(e.target.value)}
required
fullWidth
size="small"
disabled={disabled || !captcha}
autoComplete="off"
slotProps={{ htmlInput: { autoCapitalize: "off", spellCheck: false } }}
/>
</Stack>
);
}
+16 -2
View File
@@ -1,3 +1,7 @@
import Box from "@mui/material/Box";
import type { CaptchaAnswer } from "../../api/captcha";
import CaptchaField from "../CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
import React from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
@@ -55,6 +59,9 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
const [exampleProvider, setExampleProvider] = React.useState<ImportProviderKey | null>(null);
const { isImporting } = useImportJob();
const upload = useUploadImport({ categoryId, makerworldCookie, onUploaded, onUnauthorized });
const captchaSettings = useCaptchaSettings();
const [captcha, setCaptcha] = React.useState<CaptchaAnswer | null>(null);
const needsCaptcha = Boolean(captchaSettings?.import);
const detectedProvider = detectImportProvider(linkValue);
const isBlockedCollection = isMakerworldCollectionUrl(linkValue);
@@ -80,7 +87,9 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
const submitImport = async () => {
if (!linkValue.trim() || isBlockedCollection) return;
await upload.submitImport(linkValue);
if (needsCaptcha && !captcha?.captcha_answer.trim()) return;
// The dialog closes either way; reopening it shows a fresh captcha (each one works once).
await upload.submitImport(linkValue, needsCaptcha ? captcha : null);
setImportOpen(false);
};
@@ -170,13 +179,18 @@ export default function AddMenu({ categoryId, makerworldCookie, onUploaded, onUn
placeholder={t("uploadBar.linkPlaceholder") ?? undefined}
disabled={upload.importing}
/>
{needsCaptcha && (
<Box sx={{ mt: 2 }}>
<CaptchaField onChange={setCaptcha} disabled={upload.importing} />
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeImport} disabled={upload.importing}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={submitImport}
disabled={upload.importing || !linkValue.trim() || isBlockedCollection}
disabled={upload.importing || !linkValue.trim() || isBlockedCollection || (needsCaptcha && !captcha?.captcha_answer.trim())}
startIcon={upload.importing ? <CircularProgress size={14} /> : undefined}
>
{upload.importing ? t("uploadBar.importing") : t("common:import")}
@@ -122,6 +122,9 @@ function useRouteChrome() {
} else if (path.startsWith("/admin-connections")) {
title = t("adminSettings.connections.heading");
onBack = () => navigate("/admin");
} else if (path.startsWith("/admin-captcha")) {
title = t("adminSettings.captcha.heading");
onBack = () => navigate("/admin");
}
return { title, subtitle, onBack };
@@ -1,3 +1,4 @@
import type { CaptchaAnswer } from "../../api/captcha";
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -194,7 +195,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
showToast({ message: t(key, { name }) });
};
const submitImport = async (rawUrl: string) => {
const submitImport = async (rawUrl: string, captcha?: CaptchaAnswer | null) => {
const url = rawUrl.trim();
if (!url) return;
setImporting(true);
@@ -204,6 +205,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
url,
category_id: categoryId || undefined,
makerworld_cookie: cookie || undefined,
...captcha,
};
if (isMakerworldCollectionUrl(url)) {
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
import { captchaApi, type CaptchaSettings } from "../api/captcha";
// Fetched once per page load and shared by every form that may need a captcha. An admin who
// changes the settings sees them apply on the next load, same as other instance-wide settings.
let cached: Promise<CaptchaSettings> | null = null;
/** Whether each place asks for a captcha; null until known. A failed fetch counts as "off" --
* the backend still enforces the real setting, and its error then says what's missing. */
export function useCaptchaSettings(): CaptchaSettings | null {
const [settings, setSettings] = React.useState<CaptchaSettings | null>(null);
React.useEffect(() => {
let active = true;
cached ??= captchaApi.getSettings().catch(() => {
cached = null;
return { login: false, register: false, import: false };
});
void cached.then((value) => { if (active) setSettings(value); });
return () => { active = false; };
}, []);
return settings;
}
/** Forgets the cached settings -- after an admin changes them in this same browser session. */
export function invalidateCaptchaSettings(): void {
cached = null;
}
+30
View File
@@ -395,6 +395,30 @@
"connections": {
"heading": "Connections"
},
"captcha": {
"heading": "Captcha",
"placesHeading": "Ask for a captcha on",
"subtitle": "An image with a few characters to type in, to slow down automated sign-in attempts, sign-ups and imports. Off everywhere by default.",
"places": {
"login": {
"label": "Sign in",
"help": "Asked on every sign-in attempt, before the password is checked."
},
"register": {
"label": "Registration",
"help": "Asked when creating an account, including from an invitation link."
},
"import": {
"label": "Import",
"help": "Asked in the Import dialog before an import starts. Uploading your own files isn't affected."
}
},
"loadFailed": "Unable to load the captcha settings.",
"saveFailed": "Failed to save the captcha settings",
"previewHeading": "What people will see",
"previewHelp": "Letters and numbers, with easily confused ones like 0/O and 1/I left out. Not case-sensitive. Each image works once and expires after 10 minutes.",
"extensionNote": "Thingport Grab, the browser extension, is never asked for a captcha: it signs in and imports in the background, with nowhere to show one."
},
"smtp": {
"tabLabel": "SMTP",
"helpText": "Used to send the account-verification email on registration. Leave Host blank to turn email verification off entirely — new accounts are then verified and signed in immediately.",
@@ -531,6 +555,12 @@
"note": "If macOS still refuses to run it, open System Settings -> Privacy & Security and allow it there instead."
}
}
},
"captcha": {
"imageAlt": "Captcha: the characters to type",
"newImage": "New image",
"label": "Characters in the image",
"loadFailed": "Couldn't load the image. Try a new one."
}
},
"common": {
+30
View File
@@ -397,6 +397,30 @@
"connections": {
"heading": "Ryšiai"
},
"captcha": {
"heading": "Captcha",
"placesHeading": "Prašyti captcha",
"subtitle": "Paveikslėlis su keliais simboliais, kuriuos reikia įvesti, kad būtų sunkiau automatiškai jungtis, registruotis ir importuoti. Pagal nutylėjimą visur išjungta.",
"places": {
"login": {
"label": "Prisijungiant",
"help": "Prašoma kiekvieną kartą jungiantis, prieš tikrinant slaptažodį."
},
"register": {
"label": "Registruojantis",
"help": "Prašoma kuriant paskyrą, taip pat ir per kvietimo nuorodą."
},
"import": {
"label": "Importuojant",
"help": "Prašoma importavimo lange prieš pradedant importą. Savų failų įkėlimui netaikoma."
}
},
"loadFailed": "Nepavyko įkelti captcha nustatymų.",
"saveFailed": "Nepavyko išsaugoti captcha nustatymų",
"previewHeading": "Ką matys naudotojai",
"previewHelp": "Raidės ir skaičiai, be lengvai supainiojamų, pvz., 0/O ir 1/I. Didžiosios ir mažosios raidės nesvarbu. Kiekvienas paveikslėlis galioja vieną kartą ir 10 minučių.",
"extensionNote": "Thingport Grab naršyklės plėtinio captcha niekada neprašoma: jis jungiasi ir importuoja fone, kur jos parodyti neįmanoma."
},
"smtp": {
"tabLabel": "SMTP",
"helpText": "Naudojama siunčiant el. pašto patvirtinimo laišką registruojantis. Palikite Serverį (Host) tuščią, kad visiškai išjungtumėte el. pašto patvirtinimą — tuomet naujos paskyros iš karto patvirtinamos ir prijungiamos.",
@@ -533,6 +557,12 @@
"note": "Jei „macOS“ vis tiek atsisako jį paleisti, atidarykite System Settings -> Privacy & Security ir leiskite jį ten."
}
}
},
"captcha": {
"imageAlt": "Captcha: simboliai, kuriuos reikia įvesti",
"newImage": "Naujas paveikslėlis",
"label": "Paveikslėlio simboliai",
"loadFailed": "Nepavyko įkelti paveikslėlio. Bandykite naują."
}
},
"common": {
+2
View File
@@ -18,6 +18,7 @@ import HistoryIcon from "@mui/icons-material/History";
import BoltIcon from "@mui/icons-material/Bolt";
import CableIcon from "@mui/icons-material/Cable";
import PublicIcon from "@mui/icons-material/Public";
import VerifiedUserIcon from "@mui/icons-material/VerifiedUser";
import UpdateCheckSection from "./UpdateCheckSection";
import { adminApi, type StorageUsage } from "../../api/admin";
import { UnauthorizedError } from "../../api/client";
@@ -36,6 +37,7 @@ const SECTIONS: Section[] = [
{ path: "/admin-logs", icon: <HistoryIcon fontSize="small" />, labelKey: "adminSettings.logs.heading" },
{ path: "/admin-triggers", icon: <BoltIcon fontSize="small" />, labelKey: "adminSettings.triggers.heading" },
{ path: "/admin-connections", icon: <CableIcon fontSize="small" />, labelKey: "adminSettings.connections.heading" },
{ path: "/admin-captcha", icon: <VerifiedUserIcon fontSize="small" />, labelKey: "adminSettings.captcha.heading" },
];
type Props = {
+15 -1
View File
@@ -8,6 +8,9 @@ import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { authApi, type AuthUser } from "../../api/auth";
import type { CaptchaAnswer } from "../../api/captcha";
import CaptchaField from "../../components/CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
import CheckEmailPanel from "./CheckEmailPanel";
const MIN_PASSWORD_LENGTH = 8;
@@ -28,6 +31,9 @@ export default function RegisterPanel({ onSuccess, invite = null }: Props) {
// "valid" (email locked to the invited address) or "invalid" (form stays closed).
const [inviteState, setInviteState] = React.useState<"checking" | "valid" | "invalid">(invite ? "checking" : "valid");
const [inviteError, setInviteError] = React.useState<string | null>(null);
const captchaSettings = useCaptchaSettings();
const [captcha, setCaptcha] = React.useState<CaptchaAnswer | null>(null);
const [captchaKey, setCaptchaKey] = React.useState(0);
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
@@ -68,7 +74,13 @@ export default function RegisterPanel({ onSuccess, invite = null }: Props) {
}
setLoading(true);
try {
const res = await authApi.register({ displayName, email, password, inviteToken: invite?.token });
const res = await authApi.register({
displayName,
email,
password,
inviteToken: invite?.token,
captcha: captchaSettings?.register ? captcha : null,
});
if ("email_verification_required" in res) {
setPendingEmail(res.email);
} else {
@@ -79,6 +91,7 @@ export default function RegisterPanel({ onSuccess, invite = null }: Props) {
}
} catch (err) {
console.error(err);
if (captchaSettings?.register) setCaptchaKey((k) => k + 1);
setError(err instanceof Error ? err.message : t("auth.register.failed"));
} finally {
setLoading(false);
@@ -152,6 +165,7 @@ export default function RegisterPanel({ onSuccess, invite = null }: Props) {
fullWidth
size="small"
/>
{captchaSettings?.register && <CaptchaField key={captchaKey} onChange={setCaptcha} disabled={loading} />}
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.register.submitting") : t("auth.register.submit")}
</Button>
+10 -1
View File
@@ -7,6 +7,9 @@ import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import { authApi, type AuthUser } from "../../api/auth";
import { EmailNotVerifiedError } from "../../api/client";
import type { CaptchaAnswer } from "../../api/captcha";
import CaptchaField from "../../components/CaptchaField";
import { useCaptchaSettings } from "../../hooks/useCaptchaSettings";
import ResendVerificationButton from "./ResendVerificationButton";
type Props = {
@@ -20,6 +23,9 @@ export default function SignInPanel({ onSuccess }: Props) {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [needsVerification, setNeedsVerification] = React.useState(false);
const captchaSettings = useCaptchaSettings();
const [captcha, setCaptcha] = React.useState<CaptchaAnswer | null>(null);
const [captchaKey, setCaptchaKey] = React.useState(0);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -27,10 +33,12 @@ export default function SignInPanel({ onSuccess }: Props) {
setNeedsVerification(false);
setLoading(true);
try {
const res = await authApi.login(email, password);
const res = await authApi.login(email, password, captchaSettings?.login ? captcha : null);
onSuccess(res.token, res.expires_in, res.user);
} catch (err) {
console.error(err);
// Every attempt uses the captcha up, right or wrong -- show a fresh one.
if (captchaSettings?.login) setCaptchaKey((k) => k + 1);
if (err instanceof EmailNotVerifiedError) {
setNeedsVerification(true);
setError(err.message);
@@ -67,6 +75,7 @@ export default function SignInPanel({ onSuccess }: Props) {
fullWidth
size="small"
/>
{captchaSettings?.login && <CaptchaField key={captchaKey} onChange={setCaptcha} disabled={loading} />}
<Button type="submit" variant="contained" disabled={loading} fullWidth size="large">
{loading ? t("auth.signIn.submitting") : t("auth.signIn.submit")}
</Button>
+122
View File
@@ -0,0 +1,122 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Checkbox from "@mui/material/Checkbox";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import RefreshIcon from "@mui/icons-material/Refresh";
import { UnauthorizedError } from "../../api/client";
import { captchaApi, type CaptchaPlace, type CaptchaSettings } from "../../api/captcha";
import SectionHeader from "../../components/SectionHeader";
import { invalidateCaptchaSettings } from "../../hooks/useCaptchaSettings";
type Props = {
onUnauthorized?: () => void;
};
const PLACES: CaptchaPlace[] = ["login", "register", "import"];
/** Administration > Captcha: where the web app asks for an image captcha (all off by default).
* Each box saves as soon as it's ticked. The captcha itself is generated by the backend (see
* services/captchaService.ts); Thingport Grab, the browser extension, is never asked for one. */
export default function CaptchaPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [settings, setSettings] = React.useState<CaptchaSettings | null>(null);
const [saving, setSaving] = React.useState<CaptchaPlace | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const loadPreview = React.useCallback(() => {
setPreview(null);
captchaApi.get().then((c) => setPreview(c.image), () => setPreview(null));
}, []);
React.useEffect(() => {
let active = true;
captchaApi.getSettings().then(
(value) => { if (active) setSettings(value); },
() => { if (active) setError(t("adminSettings.captcha.loadFailed")); },
);
loadPreview();
return () => { active = false; };
}, [t, loadPreview]);
const toggle = async (place: CaptchaPlace, enabled: boolean) => {
if (!settings) return;
const previous = settings;
setSettings({ ...settings, [place]: enabled });
setSaving(place);
setError(null);
try {
setSettings(await captchaApi.updateSettings({ [place]: enabled }));
invalidateCaptchaSettings();
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setSettings(previous);
setError(err instanceof Error ? err.message : t("adminSettings.captcha.saveFailed"));
} finally {
setSaving(null);
}
};
return (
<Stack spacing={3} sx={{ maxWidth: 720 }}>
<SectionHeader title={t("adminSettings.captcha.placesHeading")} subtitle={t("adminSettings.captcha.subtitle")} />
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
{PLACES.map((place) => (
<Box key={place}>
<Stack direction="row" spacing={1} alignItems="center">
<FormControlLabel
control={
<Checkbox
checked={settings?.[place] ?? false}
disabled={!settings || saving !== null}
onChange={(e) => void toggle(place, e.target.checked)}
/>
}
label={t(`adminSettings.captcha.places.${place}.label`)}
/>
{saving === place && <CircularProgress size={16} />}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
{t(`adminSettings.captcha.places.${place}.help`)}
</Typography>
</Box>
))}
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
</Stack>
</Paper>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
<Typography variant="subtitle2" fontWeight={600}>{t("adminSettings.captcha.previewHeading")}</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ width: 160, height: 56, borderRadius: 1, overflow: "hidden", border: "1px solid", borderColor: "divider" }}>
{preview ? (
<Box component="img" src={preview} alt={t("captcha.imageAlt")} sx={{ width: 160, height: 56, display: "block" }} />
) : (
<Skeleton variant="rectangular" width={160} height={56} />
)}
</Box>
<Tooltip title={t("captcha.newImage")}>
<IconButton onClick={loadPreview} aria-label={t("captcha.newImage")}><RefreshIcon /></IconButton>
</Tooltip>
</Stack>
<Typography variant="body2" color="text.secondary">{t("adminSettings.captcha.previewHelp")}</Typography>
<Alert severity="info">{t("adminSettings.captcha.extensionNote")}</Alert>
</Stack>
</Paper>
</Stack>
);
}