feat(flaresolverr): make URL configurable in admin settings

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-11 19:34:29 +02:00
1 parent b15adb087b
commit 5fa6c40324
23 files changed
+459 -27

No files matched your search

+1
View File
@@ -19,5 +19,6 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Thingport <no-reply@localhost>
# first-run default only; once Thingport has started, change it in Administration -> Connections -> FlareSolverr
FLARESOLVERR_URL=http://flaresolverr:8191/v1
TZ=UTC
+1
View File
@@ -10,6 +10,7 @@ node_modules/
dist/
.vite/
coverage/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+1
View File
@@ -203,6 +203,7 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Thingport <no-reply@localhost>
# first-run default only; once Thingport has started, change it in Administration -> Connections -> FlareSolverr
FLARESOLVERR_URL=http://flaresolverr:8191/v1
TZ=UTC
```
+46 -2
View File
@@ -11,6 +11,8 @@ import {
getPreviewMode,
getSimplifyPreviews,
getSmtpSettings,
getFlaresolverrUrl,
setFlaresolverrUrl,
getThingiverseAccessToken,
setAllowRegistrations,
setAuthTokenTtl,
@@ -63,6 +65,7 @@ import { checkForUpdates } from "../services/versionService";
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
import { testAiCategorizationProvider } from "../services/aiCategorizationService";
import { testFlaresolverr } from "../services/flaresolverr";
const router = Router();
router.use(requireAuth);
@@ -288,6 +291,47 @@ router.patch(
}),
);
router.get(
"/settings/flaresolverr",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json({ url: await getFlaresolverrUrl() });
}),
);
const flaresolverrUrlSchema = z.string().trim().url("Enter a full URL, like http://flaresolverr:8191");
// A URL is only saved once FlareSolverr answers on it; clearing it turns FlareSolverr off without a test.
const flaresolverrSettingsSchema = z.object({ url: flaresolverrUrlSchema.nullable().or(z.literal("")) }).strict();
router.patch(
"/settings/flaresolverr",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(flaresolverrSettingsSchema, req.body);
if (body.url) {
const result = await testFlaresolverr(body.url);
if (!result.ok)
throw new HttpError(
422,
`Could not connect to FlareSolverr: ${result.error}`,
"flaresolverr_connection_failed",
);
}
res.json({ url: await setFlaresolverrUrl(body.url || null) });
}),
);
const flaresolverrTestSchema = z.object({ url: flaresolverrUrlSchema }).strict();
router.post(
"/settings/flaresolverr/test",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(flaresolverrTestSchema, req.body);
const result = await testFlaresolverr(body.url);
res.json(result.ok ? { ok: true, latency_ms: result.latencyMs, version: result.version } : result);
}),
);
router.get(
"/settings/database",
requireAdmin,
@@ -332,9 +376,9 @@ const MAKERWORLD_UNVERIFIABLE_MESSAGES: Record<
string
> = {
cloudflare_no_flaresolverr:
"Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr isn't configured. Set FLARESOLVERR_URL and try again.",
"Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr isn't configured. Set its URL in Administration → Connections and try again.",
flaresolverr_failed:
"Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr didn't get past it. Make sure FlareSolverr is running and reachable at FLARESOLVERR_URL, then try again.",
"Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr didn't get past it. Make sure FlareSolverr is running and reachable at the URL set in Administration → Connections, then try again.",
network:
"Couldn't check this cookie: MakerWorld didn't respond. Check this server's internet connection and try again.",
};
+3
View File
@@ -4,6 +4,7 @@ import { prisma } from "./db"; // also ensures storage directories exist before
import { startConsumeWatcher } from "./services/consumeService";
import { startCollectionSyncScheduler } from "./services/collectionSyncRunner";
import { SYNC_JOB_INTERRUPTED } from "./services/importJobRunner";
import { seedFlaresolverrUrl } from "./services/settingsService";
const app = createApp();
@@ -20,6 +21,8 @@ const app = createApp();
});
})().catch((err) => console.error("Failed to recover stale import jobs on startup:", err));
seedFlaresolverrUrl().catch((err) => console.error("Failed to seed the FlareSolverr URL setting:", err));
app.listen(API_PORT, () => {
console.log(`Thingport API listening on port ${API_PORT}`);
startConsumeWatcher();
+38 -5
View File
@@ -1,7 +1,39 @@
import { FLARESOLVERR_SESSION_TTL_MS, FLARESOLVERR_TIMEOUT_MS, FLARESOLVERR_URL } from "../config";
import { FLARESOLVERR_SESSION_TTL_MS, FLARESOLVERR_TIMEOUT_MS } from "../config";
import { getFlaresolverrUrl, normalizeFlaresolverrUrl } from "./settingsService";
export function isFlaresolverrEnabled(): boolean {
return Boolean(FLARESOLVERR_URL);
const TEST_TIMEOUT_MS = 10000;
export async function isFlaresolverrEnabled(): Promise<boolean> {
return Boolean(await getFlaresolverrUrl());
}
/** `sessions.list` is a cheap command that only FlareSolverr's /v1 endpoint answers with status "ok". Takes the
* URL with or without /v1. */
export async function testFlaresolverr(
url: string,
): Promise<{ ok: true; latencyMs: number; version: string | null } | { ok: false; error: string }> {
const endpoint = normalizeFlaresolverrUrl(url);
if (!endpoint) return { ok: false, error: "Enter the FlareSolverr URL first" };
const start = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
try {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cmd: "sessions.list" }),
signal: controller.signal,
});
if (!res.ok) return { ok: false, error: `FlareSolverr answered HTTP ${res.status}` };
const data = (await res.json().catch(() => null)) as { status?: string; version?: string } | null;
if (data?.status !== "ok") return { ok: false, error: "That URL didn't answer like FlareSolverr" };
return { ok: true, latencyMs: Date.now() - start, version: data.version ?? null };
} catch (err) {
if (controller.signal.aborted) return { ok: false, error: `No answer within ${TEST_TIMEOUT_MS / 1000}s` };
return { ok: false, error: err instanceof Error ? err.message : "Couldn't reach FlareSolverr" };
} finally {
clearTimeout(timeout);
}
}
/** A Cloudflare edge block rather than an application-level 403. */
@@ -47,7 +79,8 @@ export async function fetchViaFlaresolverr(
url: string,
cookieHeader?: string | null,
): Promise<FlaresolverrResult | null> {
if (!FLARESOLVERR_URL) return null;
const endpoint = await getFlaresolverrUrl();
if (!endpoint) return null;
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase();
@@ -67,7 +100,7 @@ export async function fetchViaFlaresolverr(
};
if (cookieList.length) body.cookies = cookieList;
const res = await fetch(FLARESOLVERR_URL, {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
+2 -2
View File
@@ -21,7 +21,6 @@ import {
import {
extractJsonFromBrowserBody,
fetchViaFlaresolverr,
isFlaresolverrEnabled,
looksLikeCloudflareBlock,
shouldProxyHost,
} from "./flaresolverr";
@@ -143,7 +142,8 @@ async function fetchCappedBuffer(
init?: { method?: string; body?: string },
): Promise<{ status: number; headers: Headers; buffer: Buffer; url: string } | null> {
const isGet = !init?.method || init.method.toUpperCase() === "GET";
if (!isGet || !isFlaresolverrEnabled()) {
// fetchViaFlaresolverr returns null when it isn't configured, so no separate enabled check (a DB read).
if (!isGet) {
return rawFetchBuffer(url, headers, init);
}
+3 -3
View File
@@ -22,7 +22,7 @@ import {
import { validateRemoteUrl } from "../utils/urlUtils";
import { readCapped } from "../utils/readCapped";
import { maybeSleep, sleep } from "../utils/concurrency";
import { fetchViaFlaresolverr, isFlaresolverrEnabled, looksLikeCloudflareBlock, shouldProxyHost } from "./flaresolverr";
import { fetchViaFlaresolverr, looksLikeCloudflareBlock, shouldProxyHost } from "./flaresolverr";
import {
emptyImportedPageMetadata,
extractNextDataJson,
@@ -136,11 +136,11 @@ async function fetchWithGuard(url: string, headers: Record<string, string>): Pro
} catch {}
let res: Response;
if (isFlaresolverrEnabled() && shouldProxyHost(hostname)) {
if (shouldProxyHost(hostname)) {
res = (await proxiedResponse(url, headers.Cookie)) || (await rawFetch(url, headers));
} else {
res = await rawFetch(url, headers);
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
if (res.status === 403 && looksLikeCloudflareBlock(res.headers)) {
const proxied = await proxiedResponse(url, headers.Cookie);
if (proxied) res = proxied;
}
+2 -2
View File
@@ -134,7 +134,7 @@ export async function verifyMakerworldCookie(rawCookieOrToken: string): Promise<
signal: controller.signal,
});
if (res.status === 403 && looksLikeCloudflareBlock(res.headers)) {
if (!isFlaresolverrEnabled()) return { result: "unverifiable", reason: "cloudflare_no_flaresolverr" };
if (!(await isFlaresolverrEnabled())) return { result: "unverifiable", reason: "cloudflare_no_flaresolverr" };
// Through FlareSolverr this answers 200 even when logged out; only a real session has a `uid`.
const solved = await fetchViaFlaresolverr(SELF_PREFERENCE_URL, `token=${bearerToken}`);
if (!solved) return { result: "unverifiable", reason: "flaresolverr_failed" };
@@ -188,7 +188,7 @@ async function fetchAuthorProfileJson(uid: string, paceMs?: number): Promise<unk
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
try {
const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal });
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
if (res.status === 403 && looksLikeCloudflareBlock(res.headers)) {
const solved = await fetchViaFlaresolverr(url, null);
if (!solved) return null;
return extractJsonFromBrowserBody(solved.body);
@@ -81,7 +81,8 @@ async function fetchCollectionJson(
redirect: "follow",
signal: controller.signal,
});
if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) {
// Checked separately, unlike the other callers: without FlareSolverr a 403 falls through to MakerworldAuthError.
if (res.status === 403 && looksLikeCloudflareBlock(res.headers) && (await isFlaresolverrEnabled())) {
const solved = await fetchViaFlaresolverr(url, bearerToken ? `token=${bearerToken}` : null);
if (!solved) return fail("blocked by Cloudflare");
const data = extractJsonFromBrowserBody(solved.body);
+39
View File
@@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { FLARESOLVERR_URL } from "../config";
async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
const row = await prisma.setting.findUnique({ where: { key } });
@@ -157,6 +158,44 @@ export async function setSmtpSettings(patch: Partial<SmtpSettings>): Promise<Smt
return next;
}
const FLARESOLVERR_URL_KEY = "flaresolverr_url";
/** FlareSolverr only answers at /v1, so the base URL works too: `http://host:8191` → `http://host:8191/v1`. */
export function normalizeFlaresolverrUrl(value: string | null | undefined): string | null {
const trimmed = (value ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return null;
return /\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/v1`;
}
// Until a row exists (seeded at startup or saved by an admin) the env var is the fallback; after that the row wins.
export async function getFlaresolverrUrl(): Promise<string | null> {
const row = await prisma.setting.findUnique({ where: { key: FLARESOLVERR_URL_KEY } });
if (row) return typeof row.value === "string" ? normalizeFlaresolverrUrl(row.value) : null;
return normalizeFlaresolverrUrl(FLARESOLVERR_URL);
}
/** Copies the env var into the DB once, at startup, so later env changes don't override what an admin saved. */
export async function seedFlaresolverrUrl(envValue: string = FLARESOLVERR_URL): Promise<void> {
const seed = normalizeFlaresolverrUrl(envValue);
if (!seed) return;
await prisma.setting.upsert({
where: { key: FLARESOLVERR_URL_KEY },
create: { key: FLARESOLVERR_URL_KEY, value: seed },
update: {},
});
}
// Cleared is stored as "" so the env var doesn't come back on the next read.
export async function setFlaresolverrUrl(value: string | null): Promise<string | null> {
const url = normalizeFlaresolverrUrl(value);
await prisma.setting.upsert({
where: { key: FLARESOLVERR_URL_KEY },
create: { key: FLARESOLVERR_URL_KEY, value: url ?? "" },
update: { value: url ?? "" },
});
return url;
}
export type AiCategorizationMode = "off" | "suggest" | "auto";
export type AiCategorizationSettings = {
mode: AiCategorizationMode;
+2 -7
View File
@@ -4,7 +4,6 @@ import { cleanThingiverseMarkdown } from "./descriptionMarkdown";
import {
extractJsonFromBrowserBody,
fetchViaFlaresolverr,
isFlaresolverrEnabled,
looksLikeCloudflareBlock,
shouldProxyHost,
} from "./flaresolverr";
@@ -98,7 +97,7 @@ async function fetchThingiverseApi(
let res: Response;
let viaBrowser = false;
if (isFlaresolverrEnabled() && shouldProxyHost(THINGIVERSE_API_HOSTNAME)) {
if (shouldProxyHost(THINGIVERSE_API_HOSTNAME)) {
const solved = await fetchViaFlaresolverr(url);
if (solved) {
viaBrowser = true;
@@ -108,11 +107,7 @@ async function fetchThingiverseApi(
}
} else {
res = await rawApiFetch(url);
if (
(res.status === 429 || res.status === 403) &&
isFlaresolverrEnabled() &&
looksLikeCloudflareBlock(res.headers)
) {
if ((res.status === 429 || res.status === 403) && looksLikeCloudflareBlock(res.headers)) {
const solved = await fetchViaFlaresolverr(url);
if (solved) {
viaBrowser = true;
@@ -0,0 +1,93 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { getFlaresolverrUrl, seedFlaresolverrUrl, setFlaresolverrUrl } from "../src/services/settingsService";
const app = createApp();
const ENDPOINT = "http://fake-flaresolverr.test/v1";
let adminToken: string;
beforeAll(async () => {
const email = `flaresolverr-admin-${Date.now()}@example.com`;
const registered = await request(app)
.post("/api/register")
.send({ displayName: "FlareSolverr Admin", email, password: "password123" });
await prisma.user.update({ where: { id: registered.body.user.id }, data: { role: "ADMIN" } });
adminToken = (await request(app).post("/api/login").send({ email, password: "password123" })).body.token;
});
const originalFetch = global.fetch;
afterEach(async () => {
global.fetch = originalFetch;
await prisma.setting.deleteMany({ where: { key: "flaresolverr_url" } });
});
function fakeFlaresolverr(answer: () => Response) {
const calls: string[] = [];
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
calls.push(String(input));
return answer();
}) as unknown as typeof fetch;
return calls;
}
const ready = () => new Response(JSON.stringify({ status: "ok", sessions: [], version: "3.3.21" }), { status: 200 });
describe("FlareSolverr URL setting", () => {
it("seeds the DB from FLARESOLVERR_URL once, then ignores the env var", async () => {
await prisma.setting.deleteMany({ where: { key: "flaresolverr_url" } });
await seedFlaresolverrUrl("http://fake-flaresolverr.test");
expect(await getFlaresolverrUrl()).toBe(ENDPOINT);
await setFlaresolverrUrl(null);
await seedFlaresolverrUrl("http://other-flaresolverr.test/v1");
expect(await getFlaresolverrUrl()).toBeNull();
});
it("tests a URL before saving it and refuses one that doesn't answer", async () => {
const calls = fakeFlaresolverr(() => new Response("nope", { status: 502 }));
const refused = await request(app)
.patch("/api/settings/flaresolverr")
.set({ Authorization: `Bearer ${adminToken}` })
.send({ url: ENDPOINT });
expect(refused.status).toBe(422);
expect(calls).toEqual([ENDPOINT]);
expect(await prisma.setting.findUnique({ where: { key: "flaresolverr_url" } })).toBeNull();
fakeFlaresolverr(ready);
const saved = await request(app)
.patch("/api/settings/flaresolverr")
.set({ Authorization: `Bearer ${adminToken}` })
.send({ url: ENDPOINT });
expect(saved.status).toBe(200);
expect(saved.body).toEqual({ url: ENDPOINT });
const tested = await request(app)
.post("/api/settings/flaresolverr/test")
.set({ Authorization: `Bearer ${adminToken}` })
.send({ url: ENDPOINT });
expect(tested.body).toMatchObject({ ok: true, version: "3.3.21" });
});
it("accepts the base URL and adds /v1 itself", async () => {
const calls = fakeFlaresolverr(ready);
const saved = await request(app)
.patch("/api/settings/flaresolverr")
.set({ Authorization: `Bearer ${adminToken}` })
.send({ url: "http://fake-flaresolverr.test/" });
expect(saved.body).toEqual({ url: ENDPOINT });
expect(calls).toEqual([ENDPOINT]);
});
it("clears the URL without testing it", async () => {
const calls = fakeFlaresolverr(ready);
const cleared = await request(app)
.patch("/api/settings/flaresolverr")
.set({ Authorization: `Bearer ${adminToken}` })
.send({ url: "" });
expect(cleared.body).toEqual({ url: null });
expect(calls).toEqual([]);
});
});
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { prisma } from "../src/db";
const ACCESS_TOKEN = "test-access-token";
const FLARESOLVERR_URL = "http://fake-flaresolverr.test/v1";
@@ -32,7 +33,8 @@ describe("fetchThingiverseApiJson FlareSolverr fallback", () => {
});
it("solves a Cloudflare challenge via FlareSolverr, then reuses the proxy for later calls to the same host", async () => {
// config.ts reads this on first import, hence the dynamic import below.
// config.ts reads this on first import, hence the dynamic import below. A saved setting would override it.
await prisma.setting.deleteMany({ where: { key: "flaresolverr_url" } });
process.env.FLARESOLVERR_URL = FLARESOLVERR_URL;
vi.resetModules();
const { resolveThingiverseThing } = await import("../src/services/thingiverseApi");
+4 -2
View File
@@ -95,13 +95,15 @@ Open <http://localhost:5173>. **The first account you register becomes the admin
## Optional: FlareSolverr
Thingiverse sits behind Cloudflare, and some imports need FlareSolverr to get through. It's
optional -- leave `FLARESOLVERR_URL` blank to disable it and those imports will simply fail.
optional -- without it those imports will simply fail.
```bash
docker run -d --name thingport-flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
```
Then set `FLARESOLVERR_URL=http://localhost:8191/v1` in `backend/.env`.
Then set its URL (`http://localhost:8191`, with or without `/v1`) in **Administration → Connections →
FlareSolverr**. `FLARESOLVERR_URL` in `backend/.env` is copied into that setting on the first startup; after that the
saved value wins and the env var is ignored.
## Running the tests
+1
View File
@@ -24,5 +24,6 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Thingport <no-reply@localhost>
# first-run default only; once Thingport has started, change it in Administration -> Connections -> FlareSolverr
FLARESOLVERR_URL=http://flaresolverr:8191/v1
TZ=UTC
+1
View File
@@ -26,5 +26,6 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Thingport <no-reply@localhost>
# first-run default only; once Thingport has started, change it in Administration -> Connections -> FlareSolverr
FLARESOLVERR_URL=http://flaresolverr:8191/v1
TZ=UTC
+1
View File
@@ -23,5 +23,6 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Thingport <no-reply@localhost>
# first-run default only; once Thingport has started, change it in Administration -> Connections -> FlareSolverr
FLARESOLVERR_URL=http://flaresolverr:8191/v1
TZ=UTC
+31
View File
@@ -290,6 +290,37 @@ export const settingsApi = {
return res.json();
},
getFlaresolverr: async (): Promise<{ url: string | null }> => {
const res = await fetch(`${apiBase()}/settings/flaresolverr`, { headers: authHeaders() });
assertOk(res, "Failed to load FlareSolverr settings");
return res.json();
},
// The backend tests a non-empty URL before saving it.
updateFlaresolverr: async (url: string | null): Promise<{ url: string | null }> => {
const res = await fetch(`${apiBase()}/settings/flaresolverr`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ url }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update FlareSolverr settings"));
return res.json();
},
testFlaresolverr: async (
url: string,
): Promise<{ ok: boolean; latency_ms?: number; version?: string | null; error?: string }> => {
const res = await fetch(`${apiBase()}/settings/flaresolverr/test`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ url }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to test FlareSolverr"));
return res.json();
},
// Host/port are fixed at startup; database/user may have been switched by "Test & Save".
getDatabase: async (): Promise<DatabaseInfo> => {
const res = await fetch(`${apiBase()}/settings/database`, { headers: authHeaders() });
+18
View File
@@ -726,6 +726,24 @@
"confirmWarning": "This repoints every database call this running instance makes at a different database. If the new credentials are wrong or unreachable, the switch is rejected and nothing changes.",
"confirmBody": "Switch to database \"{{database}}\" as user \"{{user}}\"? This does not persist across a restart — update DATABASE_URL if you want it to stick."
},
"flaresolverr": {
"tabLabel": "FlareSolverr",
"helpText": "FlareSolverr gets imports past Cloudflare challenges on MakerWorld and Thingiverse. Enter its URL (with or without /v1), or leave it blank to turn it off. Saving checks that FlareSolverr answers first.",
"urlLabel": "URL",
"urlPlaceholder": "http://flaresolverr:8191",
"statusConfigured": "FlareSolverr is configured.",
"statusNotConfigured": "FlareSolverr is not configured — imports blocked by Cloudflare will fail.",
"save": "Save",
"saving": "Saving…",
"saved": "Saved.",
"cleared": "Saved. FlareSolverr is off.",
"test": "Test",
"testing": "Testing…",
"testSuccess": "FlareSolverr {{version}} answered in {{latency}} ms.",
"testFailed": "FlareSolverr didn't answer.",
"loadFailed": "Unable to load FlareSolverr settings.",
"failed": "Failed to save FlareSolverr settings."
},
"users": {
"heading": "Users",
"columnEmail": "Email",
+18
View File
@@ -728,6 +728,24 @@
"confirmWarning": "Tai nukreipia visus šio veikiančio serverio duomenų bazės užklausas į kitą duomenų bazę. Jei nauji duomenys neteisingi arba nepasiekiami, pakeitimas atmetamas ir niekas nesikeičia.",
"confirmBody": "Perjungti į duomenų bazę „{{database}}“ naudotoju „{{user}}“? Tai neišlieka po perkrovimo — jei norite, kad pakeitimas išliktų, atnaujinkite DATABASE_URL."
},
"flaresolverr": {
"tabLabel": "FlareSolverr",
"helpText": "FlareSolverr padeda importuojant apeiti Cloudflare patikras MakerWorld ir Thingiverse svetainėse. Įveskite jo URL (su /v1 arba be jo) arba palikite tuščią, kad išjungtumėte. Prieš išsaugant patikrinama, ar FlareSolverr atsako.",
"urlLabel": "URL",
"urlPlaceholder": "http://flaresolverr:8191",
"statusConfigured": "FlareSolverr sukonfigūruotas.",
"statusNotConfigured": "FlareSolverr nesukonfigūruotas — Cloudflare užblokuoti importai nepavyks.",
"save": "Išsaugoti",
"saving": "Saugoma…",
"saved": "Išsaugota.",
"cleared": "Išsaugota. FlareSolverr išjungtas.",
"test": "Tikrinti",
"testing": "Tikrinama…",
"testSuccess": "FlareSolverr {{version}} atsakė per {{latency}} ms.",
"testFailed": "FlareSolverr neatsakė.",
"loadFailed": "Nepavyko įkelti FlareSolverr nustatymų.",
"failed": "Nepavyko išsaugoti FlareSolverr nustatymų."
},
"users": {
"heading": "Naudotojai",
"columnEmail": "El. paštas",
@@ -0,0 +1,143 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
type Props = {
onUnauthorized?: () => void;
};
/** Leaving the URL blank turns FlareSolverr off. */
export default function FlaresolverrTab({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [savedUrl, setSavedUrl] = React.useState<string | null>(null);
const [url, setUrl] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
void settingsApi
.getFlaresolverr()
.then((settings) => {
if (!active) return;
setSavedUrl(settings.url);
setUrl(settings.url ?? "");
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setError(t("adminSettings.flaresolverr.loadFailed"));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized, t]);
const save = async () => {
setSaving(true);
setStatus(null);
setError(null);
try {
const next = await settingsApi.updateFlaresolverr(url.trim() || null);
setSavedUrl(next.url);
setUrl(next.url ?? "");
setStatus(next.url ? t("adminSettings.flaresolverr.saved") : t("adminSettings.flaresolverr.cleared"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.flaresolverr.failed"));
} finally {
setSaving(false);
}
};
const test = async () => {
setTesting(true);
setStatus(null);
setError(null);
try {
const result = await settingsApi.testFlaresolverr(url.trim());
if (result.ok)
setStatus(
t("adminSettings.flaresolverr.testSuccess", { version: result.version ?? "", latency: result.latency_ms }),
);
else setError(result.error || t("adminSettings.flaresolverr.testFailed"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.flaresolverr.testFailed"));
} finally {
setTesting(false);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 4 }}>
<CircularProgress size={20} />
</Stack>
);
}
// Saving tests the URL too, so either request locks the whole form.
const busy = saving || testing;
return (
<Stack spacing={3}>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.flaresolverr.helpText")}
</Typography>
<Alert severity={savedUrl ? "success" : "warning"}>
{savedUrl
? t("adminSettings.flaresolverr.statusConfigured")
: t("adminSettings.flaresolverr.statusNotConfigured")}
</Alert>
<TextField
label={t("adminSettings.flaresolverr.urlLabel")}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("adminSettings.flaresolverr.urlPlaceholder") ?? undefined}
disabled={busy}
fullWidth
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button
variant="contained"
onClick={() => void save()}
disabled={busy}
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{saving ? t("adminSettings.flaresolverr.saving") : t("adminSettings.flaresolverr.save")}
</Button>
<Button
variant="outlined"
onClick={() => void test()}
disabled={busy || !url.trim()}
startIcon={testing ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{testing ? t("adminSettings.flaresolverr.testing") : t("adminSettings.flaresolverr.test")}
</Button>
</Stack>
{status && <Alert severity="success">{status}</Alert>}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
</Stack>
);
}
+6 -2
View File
@@ -5,12 +5,13 @@ import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import SmtpTab from "./SmtpTab";
import DatabaseTab from "./DatabaseTab";
import FlaresolverrTab from "./FlaresolverrTab";
type Props = {
onUnauthorized?: () => void;
};
type TabKey = "smtp" | "database";
type TabKey = "smtp" | "database" | "flaresolverr";
export default function ConnectionsPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
@@ -25,8 +26,11 @@ export default function ConnectionsPage({ onUnauthorized }: Props) {
>
<Tab value="smtp" label={t("adminSettings.smtp.tabLabel")} />
<Tab value="database" label={t("adminSettings.database.tabLabel")} />
<Tab value="flaresolverr" label={t("adminSettings.flaresolverr.tabLabel")} />
</Tabs>
{tab === "smtp" ? <SmtpTab onUnauthorized={onUnauthorized} /> : <DatabaseTab onUnauthorized={onUnauthorized} />}
{tab === "smtp" && <SmtpTab onUnauthorized={onUnauthorized} />}
{tab === "database" && <DatabaseTab onUnauthorized={onUnauthorized} />}
{tab === "flaresolverr" && <FlaresolverrTab onUnauthorized={onUnauthorized} />}
</Stack>
);
}