mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat(flaresolverr): make URL configurable in admin settings
This commit is contained in:
1 parent
b15adb087b
commit
5fa6c40324
23 files changed
+459
-27
No files matched your search
@@ -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
|
||||
@@ -10,6 +10,7 @@ node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user