mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: add ability to enable catpcha
This commit is contained in:
1 parent
7d51458988
commit
6a6a87e38d
25 files changed
+811
-7
No files matched your search
Generated
+32
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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,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!);
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user