feat(qbittorrent): login compat for v4/v5.0-5.2 and three auth modes
- Login success = HTTP 200 "Ok." or 204 (5.2+); session cookie taken from
Set-Cookie by name (SID / QBT_SID_<port> / custom) and sent back as-is.
- Failure classes: "Fails." / 401 -> auth; 403 IP ban / 429 -> rate;
wrong API Key (403) -> auth with hint; 5xx -> protocol.
- Downloader instance auth kind: password / api_key (qB 5.2+ Bearer, no
/auth/login) / none (whitelisted subnet); stored in settings.authKind,
credential write-only in secret_ref, cleared when switching to none.
One qbAuthConfig() shared by sync, agent capability and connection test.
- Settings form: auth kind select, password / API Key fields (no echo).
- testkit: mock qB 5.2 login behaviour, IP ban, bypassAuth;
makeTorrent({ realPieces }) builds real piece hashes for real qB checks.
- Real-container matrix (QB_REAL, skipped by default): 5.1.2 and 5.2.4,
login / add / query / tags / pause-resume / delete keeping data.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c846e91cc4
commit
74f1566985
14 files changed
+756
-59
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
getSiteAdapter,
|
||||
getSiteDefinition,
|
||||
type ProbeResult,
|
||||
qbAuthConfig,
|
||||
type SecretResolver,
|
||||
SiteError,
|
||||
type TorrentSource,
|
||||
@@ -97,12 +98,10 @@ export function createPorts(
|
||||
if (hit && hit.stamp === stamp) return hit.client;
|
||||
const settings = (inst.settings ?? {}) as Record<string, unknown>;
|
||||
const secret = await resolveSecret(inst.secret_ref);
|
||||
const useApiKey = settings.authKind === "api_key";
|
||||
const client = createQbClient({
|
||||
instanceId: inst.id,
|
||||
baseUrl: inst.base_url,
|
||||
...(inst.username ? { username: inst.username } : {}),
|
||||
...(secret ? (useApiKey ? { apiKey: secret } : { password: secret }) : {}),
|
||||
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
|
||||
});
|
||||
clients.set(inst.id, { stamp, client });
|
||||
return client;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createQbClient, type DownloaderClient } from "@umt/adapters";
|
||||
import { createQbClient, type DownloaderClient, qbAuthConfig } from "@umt/adapters";
|
||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
||||
import type { DownloaderInstanceRow } from "@umt/db";
|
||||
@@ -20,8 +20,7 @@ async function defaultClient(ctx: ServerContext, inst: DownloaderInstanceRow): P
|
||||
return createQbClient({
|
||||
instanceId: inst.id,
|
||||
baseUrl: inst.base_url,
|
||||
...(inst.username ? { username: inst.username } : {}),
|
||||
...(secret ? (settings.authKind === "api_key" ? { apiKey: secret } : { password: secret }) : {}),
|
||||
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, writeRootSentinel } from "@umt/adapters";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, EmbyClient, QbClient, qbAuthConfig, writeRootSentinel } from "@umt/adapters";
|
||||
import {
|
||||
Api,
|
||||
type AuditAction,
|
||||
type DownloaderAuthKind,
|
||||
DownloaderInstance,
|
||||
DownloaderInstanceCreate,
|
||||
DownloaderInstanceUpdate,
|
||||
@@ -238,17 +239,36 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
// ── downloader_instance ──
|
||||
list("downloader_instance", "downloader-instances", DL.list);
|
||||
get("downloader_instance", "downloader-instances", DL.get);
|
||||
/** 认证方式并入 settings.authKind;账密需要用户名,API Key 需要凭证(新填或已存) */
|
||||
const dlAuth = (
|
||||
authKind: DownloaderAuthKind | undefined,
|
||||
next: { username?: string | null; credential?: string; settings?: Record<string, unknown> },
|
||||
cur?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const curSettings = (cur?.settings ?? {}) as Record<string, unknown>;
|
||||
if (authKind === undefined) return next.settings ? { ...curSettings, ...next.settings } : undefined;
|
||||
const username = next.username !== undefined ? next.username : ((cur?.username as string | null) ?? null);
|
||||
if (authKind === "password" && !username) throw new ApiException("validation_failed", "账密认证需要填写用户名");
|
||||
if (authKind === "api_key" && !next.credential && !(cur?.secret_ref && curSettings.authKind === "api_key"))
|
||||
throw new ApiException("validation_failed", "API Key 认证需要填写 API Key");
|
||||
return { ...curSettings, ...(next.settings ?? {}), authKind };
|
||||
};
|
||||
endpoint(app, DL.create, async ({ body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const ref = credential ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
||||
const { credential, authKind, ...rest } = body;
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
|
||||
const keep = authKind !== "none" && credential;
|
||||
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
|
||||
try {
|
||||
const row = await insert("downloader_instance", { ...toDb(rest, JSON_COLS), secret_ref: ref });
|
||||
const row = await insert("downloader_instance", {
|
||||
...toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS),
|
||||
secret_ref: ref,
|
||||
});
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
"downloader_instance.create",
|
||||
{ type: "downloader_instance", id: row.id },
|
||||
{ name: row.name },
|
||||
{ name: row.name, authKind: authKind ?? null },
|
||||
);
|
||||
return toApi(row);
|
||||
} catch (e) {
|
||||
@@ -257,13 +277,22 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
}
|
||||
});
|
||||
endpoint(app, DL.update, async ({ params, body, actor }) => {
|
||||
const { credential, ...rest } = body;
|
||||
const values = toDb(rest, JSON_COLS);
|
||||
if (credential) {
|
||||
const cur = await getRow("downloader_instance", params.id);
|
||||
const { credential, authKind, ...rest } = body;
|
||||
const cur = await getRow("downloader_instance", params.id);
|
||||
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
|
||||
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
|
||||
let dropRef: string | null = null;
|
||||
if (authKind === "none") {
|
||||
// 无认证:不再保留密码 / Key
|
||||
if (cur.secret_ref) {
|
||||
values.secret_ref = null;
|
||||
dropRef = cur.secret_ref;
|
||||
}
|
||||
} else if (credential) {
|
||||
values.secret_ref = await putCredential(cur.secret_ref, "downloader_password", credential);
|
||||
}
|
||||
const row = await update("downloader_instance", params.id, values);
|
||||
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
|
||||
await audit(
|
||||
ctx,
|
||||
actor,
|
||||
@@ -285,12 +314,15 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
async ({ params, actor }) => {
|
||||
const row = await getRow("downloader_instance", params.id);
|
||||
const secret = row.secret_ref ? await ctx.secrets.resolve(row.secret_ref) : undefined;
|
||||
const username = (row.username as string | null) ?? undefined;
|
||||
// 有用户名走 WebUI 登录(密码);没有用户名但有凭证视为 qB 5.2+ WebUI API Key
|
||||
// 认证方式三选一(settings.authKind):账密 / API Key(qB 5.2+,Bearer) / 无认证;旧数据按用户名有无推断
|
||||
const client = new QbClient({
|
||||
instanceId: row.id,
|
||||
baseUrl: String(row.base_url),
|
||||
...(username ? { username, ...(secret ? { password: secret } : {}) } : secret ? { apiKey: secret } : {}),
|
||||
...qbAuthConfig({
|
||||
authKind: ((row.settings ?? {}) as Record<string, unknown>).authKind,
|
||||
username: (row.username as string | null) ?? null,
|
||||
secret: secret ?? null,
|
||||
}),
|
||||
timeoutMs: 10_000,
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user