Compare commits

..

11 Commits

Author SHA1 Message Date
kikootwo 5f62ba7146 Bump version to 1.2.0 and update tests
Update package.json version to 1.2.0 and adjust tests to explicitly click the 'Cancel request' button. This adds an extra fireEvent.click for the 'Cancel request' role in RequestActionsDropdown.test.tsx and RequestCard.test.tsx to ensure the cancel handler is invoked reliably. Files changed: package.json, package-lock.json, tests/app/admin/components/RequestActionsDropdown.test.tsx, tests/components/requests/RequestCard.test.tsx.
2026-05-15 15:12:31 -04:00
kikootwo bc7fff9dd7 Add credential recovery script, docs, and Redis wait
Introduce an interactive credential recovery tool (scripts/recover-credentials.js) and accompanying documentation (documentation/admin-features/credential-recovery.md). Add npm script rmab:recover to package.json and wire the doc into TABLEOFCONTENTS.md. Improve docker/unified/app-start.sh to wait for local Redis to finish loading before initializing app services to avoid "LOADING" errors when queues start. The recovery script uses Prisma, runs entirely interactively via docker exec -it, performs DB changes in a single transaction, and persists a rotated CONFIG_ENCRYPTION_KEY to /app/config/.secrets and /etc/environment when needed.
2026-05-15 12:04:19 -04:00
kikootwo b775ccf473 Add cancel confirmation and cancellable statuses
Introduce a unified CANCELLABLE_STATUSES constant and add confirmation UI for cancelling requests. RequestActionsDropdown and RequestCard now show a ConfirmModal before cancelling and use the shared CANCELLABLE_STATUSES to gate cancel actions. The API route imports the constant to enforce server-side validation and uses Prisma.DbNull for selectedTorrent when withdrawing an awaiting-approval request. Tests updated to expect Prisma.DbNull. Improves UX and centralizes cancel logic to avoid duplicated status lists.
2026-05-15 09:49:42 -04:00
kikootwo 1a9aeb4713 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:46:28 -04:00
kikootwo 4b79b11987 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:44:06 -04:00
kikootwo 071c788ead Add series metadata tagging and tests
Include series and seriesPart metadata when tagging audio files. For m4b output the code uses show and episode_id; for mp3 and flac it writes SERIES and SERIES-PART. Adds unit tests verifying tag output for .m4b, .mp3, and .flac and that tags are omitted when fields are absent.
2026-05-15 06:42:17 -04:00
kikootwo f4fe6f936f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:38:57 -04:00
kikootwo d2c90de07f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:30:53 -04:00
kikootwo 07fbff1133 Add tests for BigInt duration overflow (Plex)
Add regression tests to verify durations exceeding INT4 max are persisted as BigInt for Plex flows. Tests added in plex-recently-added.processor.test.ts and scan-plex.processor.test.ts cover both create and update paths (regression #193), mock the observed overflow (~4,082,750s → 4,082,750,000ms) and assert prisma.create/prisma.update are called with BigInt duration values.
2026-05-15 06:27:42 -04:00
kikootwo de72180bdd Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:13:34 -04:00
kikootwo ad8d44bae0 Support auth-optional mode for qBittorrent
Add auth-optional support when both username and password are blank. Introduce authOptional flag and authHeaders() helper to omit Cookie when unauthenticated; make login() a no-op in auth-optional mode and avoid pointless re-login on 403. Adjust many API calls to respect auth-optional behavior and update testConnection/testConnectionWithCredentials to probe /app/version for connectivity in auth-optional scenarios and return clearer errors. Add unit tests covering the new auth-optional flows and header behavior.
2026-05-15 05:54:25 -04:00
19 changed files with 1386 additions and 71 deletions
+23
View File
@@ -99,6 +99,29 @@ if [ "$READY" = "false" ]; then
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else
# =========================================================================
# WAIT FOR REDIS TO FINISH LOADING (internal Redis only)
# =========================================================================
# Redis returns "LOADING Redis is loading the dataset in memory" while it
# replays its AOF/RDB on startup. /api/health only checks Postgres, so it
# passes before Redis is actually ready to accept commands. Without this
# wait, /api/init kicks off Bull queues that flood the log with LOADING
# errors until the retry loop catches up.
if [ "$USE_EXTERNAL_REDIS" != "true" ]; then
REDIS_READY_TIMEOUT=${REDIS_READY_TIMEOUT:-60}
echo "[App] Waiting for Redis to finish loading (timeout: ${REDIS_READY_TIMEOUT}s)..."
for i in $(seq 1 "$REDIS_READY_TIMEOUT"); do
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -q '^PONG$'; then
echo "[App] Redis is ready (took ${i}s)"
break
fi
if [ "$i" -eq "$REDIS_READY_TIMEOUT" ]; then
echo "[App] WARNING: Redis did not become ready within ${REDIS_READY_TIMEOUT}s - proceeding anyway"
fi
sleep 1
done
fi
# =========================================================================
# INITIALIZE APPLICATION SERVICES
# =========================================================================
+3
View File
@@ -8,6 +8,7 @@
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
## Configuration & Setup
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
@@ -143,6 +144,8 @@
**"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
**"Local admin can't log in / 'Invalid username or password' with correct credentials"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I recover from a lost CONFIG_ENCRYPTION_KEY?"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
@@ -0,0 +1,75 @@
# Credential Recovery Script
**Status:** ✅ Implemented | Interactive recovery for lost `CONFIG_ENCRYPTION_KEY` or forgotten local admin password
## Overview
Recovers from the "Invalid username or password" failure mode caused by a lost or rotated `CONFIG_ENCRYPTION_KEY`. Detects whether the key still works; either does a minimal password reset (preserves everything) or full recovery (rotates key + clears credentials that can no longer be decrypted).
## When to Use
- Local admin gets "Invalid username or password" with credentials known to be correct
- `/app/config/.secrets` was lost, truncated, or recreated
- After an unintended `CONFIG_ENCRYPTION_KEY` change
- See GitHub issue #200 for the symptom pattern
## How to Run
```
docker exec -it <container-name> npm run rmab:recover
```
- `-it` is required for the interactive prompts
- Or directly: `docker exec -it <container-name> node /app/scripts/recover-credentials.js`
## What It Does
1. Loads `DATABASE_URL` and `CONFIG_ENCRYPTION_KEY` from env (falls back to `/etc/environment`)
2. Diagnoses key health by attempting to decrypt an existing encrypted Configuration row
3. Lists local users (`authProvider='local'`, not soft-deleted); prompts for one
4. Prompts for new password twice (masked); validates length unless `ALLOW_WEAK_PASSWORD=true`
5. Prints the exact plan (mode + what will be cleared); requires typing `confirm` verbatim
6. Executes inside a single Prisma `$transaction`
7. If key was rotated: writes new key to `/app/config/.secrets` and `/etc/environment`
## Two Modes (auto-detected)
**Simple Password Reset (key works):**
- Only updates the chosen user's `authToken` (new bcrypt, re-encrypted)
- No other data touched
- No container restart needed
**Full Recovery (key broken):**
- Generates new `CONFIG_ENCRYPTION_KEY` (32 random bytes, base64)
- For each `Configuration` row with `encrypted=true`: re-encrypts with new key if old decrypt succeeds, deletes the row if not
- For `download_clients` JSON: re-encrypts each client password if possible, blanks it if not (URL/host/etc. preserved)
- For all `User.authToken` values: re-encrypts if possible, clears if not (Plex/OIDC users re-OAuth on next login)
- Overwrites target user's `authToken` with fresh bcrypt encrypted with new key
- Writes new key to `.secrets` + `/etc/environment`
- **Container restart required after this mode**
## What Survives (Full Recovery Mode)
- All requests + request history
- Library mappings, organization templates, schedules, user accounts
- Non-encrypted Configuration rows (paths, log level, backend mode, etc.)
- Plex/OIDC users whose tokens decrypted successfully (no re-OAuth needed)
## What User Re-enters After Full Recovery
- Plex auth token (or re-OAuth via login)
- Audiobookshelf API token (if used)
- OIDC client secret (if used)
- Prowlarr API key
- Download client passwords (per client)
- Any AI / Hardcover / Goodreads / notification provider secrets
## Security
- CLI only — no HTTP endpoint, no auto-run, no rescue-mode env flag
- Requires `docker exec` access (= host root equivalent)
- Refuses to accept any CLI arguments — all input via interactive prompts
- Does not echo or log password or key values
- Operation summary written to stdout; full audit info to app logger
- Idempotent within a single mode (re-runs are safe)
## Failure Modes
- DB transaction fails → no changes committed, safe to re-run
- DB transaction commits but `.secrets`/`/etc/environment` write fails → script prints the new key in plaintext with instructions for manual write (one-time exposure in operator's terminal)
## Related
- `backend/services/auth.md` — local auth flow + the decrypt-then-compare path
- `backend/services/config.md` — encryption format details
- `deployment/unified.md` — entrypoint behavior and `.secrets` persistence
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "readmeabook",
"version": "1.0.15",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "readmeabook",
"version": "1.0.15",
"version": "1.2.0",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.8",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,7 +13,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"db:push": "prisma db push"
"db:push": "prisma db push",
"rmab:recover": "node scripts/recover-credentials.js"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
+772
View File
@@ -0,0 +1,772 @@
/**
* Component: Credential Recovery Script
* Documentation: documentation/admin-features/credential-recovery.md
*
* Interactive recovery for lost CONFIG_ENCRYPTION_KEY or forgotten local admin password.
* Run inside the container with: docker exec -it <container> npm run rmab:recover
*
* Hard rules:
* - No CLI arguments accepted. All input via interactive prompts.
* - Never log password or key values.
* - All DB mutations inside a single transaction.
* - File writes happen only after DB commit succeeds.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const readline = require('readline');
const bcrypt = require('bcrypt');
const SECRETS_FILE = '/app/config/.secrets';
const ENVIRONMENT_FILE = '/etc/environment';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const ENCRYPTED_CONFIG_KEYS_FOR_PROBE = [
'plex_token',
'prowlarr_api_key',
'audiobookshelf.api_token',
'oidc.client_secret',
];
// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------
// docker exec doesn't inherit runtime-generated env vars, and /etc/environment
// can drift from what the running app process is actually using (e.g. if
// .secrets was regenerated on a restart while the existing pg_user kept its
// original password). The source of truth is the live node process's
// /proc/<pid>/environ — read that first, then fall back to files.
// ---------------------------------------------------------------------------
const WANTED_ENV_KEYS = [
'DATABASE_URL',
'CONFIG_ENCRYPTION_KEY',
'POSTGRES_PASSWORD',
'POSTGRES_USER',
'POSTGRES_DB',
'ALLOW_WEAK_PASSWORD',
];
const envSource = {}; // key -> short label of where it came from
// The dockerfile bakes ENV DATABASE_URL=<this> at build time so prisma generate
// has a valid URL; the entrypoint overrides at runtime. But if the override
// didn't propagate to the child process inheriting via docker exec, we see
// this exact dummy value. Never trust it.
const DUMMY_DB_URL = 'postgresql://dummy:dummy@localhost:5432/dummy?schema=public';
function isUsableValue(key, value) {
if (value == null || value === '') return false;
if (key === 'DATABASE_URL' && value === DUMMY_DB_URL) return false;
if (key === 'DATABASE_URL' && /^postgresql:\/\/dummy:dummy@/.test(value)) return false;
return true;
}
function setIfMissing(key, value, sourceLabel) {
if (!isUsableValue(key, value)) return;
if (!isUsableValue(key, process.env[key])) {
process.env[key] = value;
envSource[key] = sourceLabel;
}
}
// Wipe inherited dummy URL up front so file/proc sources have a clean slate.
if (process.env.DATABASE_URL && !isUsableValue('DATABASE_URL', process.env.DATABASE_URL)) {
delete process.env.DATABASE_URL;
}
function loadEnvFromFile(filePath, sourceLabel) {
if (!fs.existsSync(filePath)) return;
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (_err) {
return;
}
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
setIfMissing(key, value, sourceLabel);
}
}
function loadEnvFromRunningProcess() {
// Walk every readable /proc/<pid>/environ. Pick the first process whose
// environ contains a non-empty DATABASE_URL. Do NOT filter by comm name —
// the app may run under gosu, npm, next-server, etc.
let procDir;
try {
procDir = fs.readdirSync('/proc');
} catch (_err) {
return null;
}
const ownPid = String(process.pid);
for (const entry of procDir) {
if (!/^\d+$/.test(entry)) continue;
if (entry === ownPid) continue;
let environBuf;
try {
environBuf = fs.readFileSync(`/proc/${entry}/environ`);
} catch (_err) {
// environ may be mode 400 owned by another user; skip silently.
continue;
}
if (!environBuf || environBuf.length === 0) continue;
const pairs = environBuf.toString('utf8').split('\u0000');
const collected = {};
for (const p of pairs) {
const eq = p.indexOf('=');
if (eq === -1) continue;
collected[p.slice(0, eq)] = p.slice(eq + 1);
}
if (!collected.DATABASE_URL) continue;
let comm = '';
try {
comm = fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
} catch (_e) {}
const label = `pid ${entry}${comm ? ` (${comm})` : ''}`;
for (const k of WANTED_ENV_KEYS) {
if (collected[k]) setIfMissing(k, collected[k], label);
}
return label;
}
return null;
}
// Priority order: /etc/environment (entrypoint's persisted authoritative state)
// > /app/config/.secrets (persisted keys) > /proc/<pid>/environ (running process).
// The inherited docker-exec env was already wiped of the dummy URL above.
loadEnvFromFile(ENVIRONMENT_FILE, '/etc/environment');
loadEnvFromFile(SECRETS_FILE, '/app/config/.secrets');
const liveProcPid = loadEnvFromRunningProcess();
// Last resort: construct DATABASE_URL from POSTGRES_PASSWORD + sensible defaults,
// mirroring what entrypoint.sh does. Works as long as POSTGRES_PASSWORD was
// recoverable from .secrets or another source.
function urlEncodePassword(s) {
// Match entrypoint.sh urlencode(): everything except [-_.~a-zA-Z0-9] is %xx.
return Array.from(s).map((c) => {
if (/[-_.~a-zA-Z0-9]/.test(c)) return c;
return '%' + c.charCodeAt(0).toString(16).padStart(2, '0');
}).join('');
}
if (!isUsableValue('DATABASE_URL', process.env.DATABASE_URL) && process.env.POSTGRES_PASSWORD) {
const user = process.env.POSTGRES_USER || 'readmeabook';
const db = process.env.POSTGRES_DB || 'readmeabook';
const host = '127.0.0.1';
const port = '5432';
const encoded = urlEncodePassword(process.env.POSTGRES_PASSWORD);
process.env.DATABASE_URL = `postgresql://${user}:${encoded}@${host}:${port}/${db}`;
envSource.DATABASE_URL = 'constructed from POSTGRES_PASSWORD + defaults';
}
// ---------------------------------------------------------------------------
// Encryption helpers (mirrors src/lib/services/encryption.service.ts)
// ---------------------------------------------------------------------------
function deriveKey(rawKey) {
if (!rawKey) {
throw new Error('CONFIG_ENCRYPTION_KEY is not set');
}
if (rawKey.length < KEY_LENGTH) {
const buf = Buffer.alloc(KEY_LENGTH);
Buffer.from(rawKey).copy(buf);
return buf;
}
if (rawKey.length > KEY_LENGTH) {
return Buffer.from(rawKey).subarray(0, KEY_LENGTH);
}
return Buffer.from(rawKey);
}
function decryptWithKey(encryptedData, keyBuffer) {
const parts = String(encryptedData || '').split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted data format');
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(parts[2], 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function encryptWithKey(plaintext, keyBuffer) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
function tryDecrypt(encryptedData, keyBuffer) {
try {
return { ok: true, value: decryptWithKey(encryptedData, keyBuffer) };
} catch (err) {
return { ok: false, error: err };
}
}
function generateNewKey() {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
// ---------------------------------------------------------------------------
// Prompt helpers
// ---------------------------------------------------------------------------
function ask(rl, question) {
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
}
function askHidden(question) {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
reject(new Error('Interactive password input requires a TTY. Run with: docker exec -it ...'));
return;
}
process.stdout.write(question);
const stdin = process.stdin;
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
let buffer = '';
const onData = (chunk) => {
for (const ch of chunk) {
if (ch === '\u0003') {
// Ctrl+C
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
reject(new Error('Cancelled by user'));
return;
}
if (ch === '\r' || ch === '\n') {
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(buffer);
return;
}
if (ch === '\u007f' || ch === '\b') {
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
if (ch < ' ') continue;
buffer += ch;
process.stdout.write('*');
}
};
stdin.on('data', onData);
});
}
// ---------------------------------------------------------------------------
// .secrets / /etc/environment file updates
// ---------------------------------------------------------------------------
function updateKeyInFile(filePath, keyName, newValue, quoted) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
`${keyName}=${quoted ? `"${newValue}"` : newValue}\n`,
{ mode: 0o600 }
);
return { created: true, replaced: false };
}
const original = fs.readFileSync(filePath, 'utf8');
const lines = original.split('\n');
let replaced = false;
const updated = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = trimmed.indexOf('=');
if (eq === -1) return line;
const name = trimmed.slice(0, eq).trim();
if (name !== keyName) return line;
replaced = true;
return `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
});
if (!replaced) {
if (updated[updated.length - 1] === '') {
updated[updated.length - 1] = `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
updated.push('');
} else {
updated.push(`${keyName}=${quoted ? `"${newValue}"` : newValue}`);
}
}
fs.writeFileSync(filePath, updated.join('\n'));
return { created: false, replaced };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Reject any CLI args by design.
if (process.argv.length > 2) {
console.error('This script does not accept CLI arguments. All input is via interactive prompts.');
console.error('Run: docker exec -it <container> npm run rmab:recover');
process.exit(2);
}
console.log('');
console.log('================================================================');
console.log(' ReadMeABook — Credential Recovery');
console.log('================================================================');
console.log('');
console.log('Use when local login fails with "Invalid username or password"');
console.log('despite known-correct credentials. See:');
console.log(' documentation/admin-features/credential-recovery.md');
console.log('');
// Diagnostic: where did we resolve env vars from?
const dbSrc = envSource.DATABASE_URL || (process.env.DATABASE_URL ? 'inherited' : 'NOT FOUND');
const keySrc = envSource.CONFIG_ENCRYPTION_KEY || (process.env.CONFIG_ENCRYPTION_KEY ? 'inherited' : 'NOT FOUND');
console.log('Environment:');
console.log(` Live process w/ DATABASE_URL: ${liveProcPid || 'none found'}`);
console.log(` DATABASE_URL source: ${dbSrc}`);
console.log(` CONFIG_ENCRYPTION_KEY src: ${keySrc}`);
if (process.env.DATABASE_URL) {
const redacted = String(process.env.DATABASE_URL).replace(/(:\/\/[^:]+:)[^@]+(@)/, '$1***$2');
console.log(` DATABASE_URL (redacted): ${redacted}`);
}
console.log('');
if (!process.env.DATABASE_URL) {
console.error('ERROR: DATABASE_URL is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
console.error(' Workaround: docker exec -it -e DATABASE_URL="<your url>" <container> npm run rmab:recover');
process.exit(1);
}
if (!process.env.CONFIG_ENCRYPTION_KEY) {
console.error('ERROR: CONFIG_ENCRYPTION_KEY is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
process.exit(1);
}
const currentKey = deriveKey(process.env.CONFIG_ENCRYPTION_KEY);
// Load Prisma client (generated in container at src/generated/prisma)
let PrismaClient;
try {
({ PrismaClient } = require(path.join(__dirname, '..', 'src', 'generated', 'prisma', 'client')));
} catch (err) {
try {
({ PrismaClient } = require('@prisma/client'));
} catch (innerErr) {
console.error('ERROR: Could not load Prisma client. Tried generated path and @prisma/client.');
console.error(' Generated path error:', err.message);
console.error(' Package error: ', innerErr.message);
process.exit(1);
}
}
const prisma = new PrismaClient();
try {
// -------------------------------------------------------------------------
// Diagnose key health
// -------------------------------------------------------------------------
console.log('Step 1/5 — Diagnosing encryption key health...');
const encryptedRows = await prisma.configuration.findMany({
where: { encrypted: true },
});
let keyWorks = null; // null = unknown (no probe rows)
let probedKey = null;
for (const row of encryptedRows) {
if (!row.value) continue;
const result = tryDecrypt(row.value, currentKey);
if (result.ok) {
keyWorks = true;
probedKey = row.key;
break;
}
if (keyWorks === null) keyWorks = false;
}
if (keyWorks === true) {
console.log(` Key works (verified against Configuration row "${probedKey}").`);
} else if (keyWorks === false) {
console.log(` Key DOES NOT work — none of the ${encryptedRows.length} encrypted Configuration rows decrypt.`);
} else {
console.log(' No encrypted Configuration rows exist yet — defaulting to password-reset-only mode.');
}
// -------------------------------------------------------------------------
// List local users
// -------------------------------------------------------------------------
console.log('');
console.log('Step 2/5 — Selecting local user to reset...');
const localUsers = await prisma.user.findMany({
where: { authProvider: 'local', deletedAt: null },
select: {
id: true,
plexUsername: true,
plexId: true,
role: true,
isSetupAdmin: true,
authToken: true,
},
orderBy: [{ isSetupAdmin: 'desc' }, { plexUsername: 'asc' }],
});
if (localUsers.length === 0) {
console.error('');
console.error('ERROR: No local users exist in the database.');
console.error(' Use the setup wizard / registration page to create one instead.');
process.exit(1);
}
console.log('');
console.log(' Local users:');
for (const u of localUsers) {
const tag = [u.role];
if (u.isSetupAdmin) tag.push('setup-admin');
console.log(` - ${u.plexUsername} [${tag.join(', ')}]`);
}
console.log('');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let chosenUser = null;
while (!chosenUser) {
const typed = (await ask(rl, ' Username to reset: ')).trim().toLowerCase();
if (!typed) continue;
chosenUser = localUsers.find((u) => u.plexUsername === typed);
if (!chosenUser) {
console.log(` No local user named "${typed}". Try again, or Ctrl+C to abort.`);
}
}
// -------------------------------------------------------------------------
// New password
// -------------------------------------------------------------------------
console.log('');
console.log('Step 3/5 — New password...');
const allowWeak = process.env.ALLOW_WEAK_PASSWORD === 'true';
const minLen = allowWeak ? 1 : 8;
let newPassword = null;
while (!newPassword) {
rl.pause();
const a = await askHidden(' New password: ');
const b = await askHidden(' Confirm new password: ');
rl.resume();
if (a !== b) {
console.log(' Passwords did not match. Try again.');
continue;
}
if (a.length < minLen) {
console.log(` Password must be at least ${minLen} character(s). Try again.`);
continue;
}
newPassword = a;
}
// -------------------------------------------------------------------------
// Build the plan
// -------------------------------------------------------------------------
console.log('');
console.log('Step 4/5 — Plan...');
console.log('');
const fullRecovery = keyWorks === false;
if (fullRecovery) {
console.log(' MODE: FULL RECOVERY (encryption key is unrecoverable)');
console.log('');
console.log(' The following will happen, atomically:');
console.log(` 1. A new CONFIG_ENCRYPTION_KEY will be generated.`);
console.log(` 2. User "${chosenUser.plexUsername}" will get a new password (bcrypt + new key).`);
console.log(' 3. Every Configuration row with encrypted=true will be tried with the OLD key:');
console.log(' - If it decrypts: re-encrypted with the new key (preserved).');
console.log(' - If it cannot decrypt: DELETED (must be re-entered in Settings).');
console.log(' 4. download_clients JSON: each per-client password tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: blanked. URL, host, name, etc. preserved.');
console.log(' 5. User.authToken for every user tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: cleared. Plex/OIDC users re-OAuth on next login.');
console.log(' 6. /app/config/.secrets and /etc/environment updated with the new key.');
console.log('');
console.log(' Likely to need re-entering in Settings after this completes:');
console.log(' - Plex auth token (or just re-login with Plex)');
console.log(' - Audiobookshelf API token (if used)');
console.log(' - Prowlarr API key');
console.log(' - OIDC client secret (if used)');
console.log(' - Download client passwords (per client)');
console.log(' - Any AI / Hardcover / Goodreads / notification provider secrets');
console.log('');
console.log(' Survives untouched:');
console.log(' - All requests + request history');
console.log(' - Library mappings, organization templates, schedules');
console.log(' - User accounts (just credentials cleared)');
console.log(' - Non-encrypted config (paths, log level, backend mode, etc.)');
console.log('');
console.log(' Container restart REQUIRED after this completes.');
} else {
console.log(' MODE: PASSWORD RESET ONLY (encryption key is healthy)');
console.log('');
console.log(` Only one change: user "${chosenUser.plexUsername}" gets a new password.`);
console.log(' Everything else (all credentials, all settings) untouched.');
console.log(' No container restart needed.');
}
console.log('');
const confirm = (await ask(rl, " Type 'confirm' to proceed (anything else aborts): ")).trim();
if (confirm !== 'confirm') {
console.log(' Aborted. No changes made.');
rl.close();
await prisma.$disconnect();
process.exit(0);
}
rl.close();
// -------------------------------------------------------------------------
// Execute
// -------------------------------------------------------------------------
console.log('');
console.log('Step 5/5 — Applying changes...');
let summary;
let newKeyBase64 = null;
let newKeyBuffer = currentKey;
if (fullRecovery) {
newKeyBase64 = generateNewKey();
newKeyBuffer = deriveKey(newKeyBase64);
// Plan mutations in memory using OLD key for reads, NEW key for writes.
const configUpdates = [];
const configDeletes = [];
let downloadClientsUpdate = null;
const userUpdates = [];
// Configuration rows
for (const row of encryptedRows) {
if (!row.value) {
configDeletes.push(row.key);
continue;
}
const decrypted = tryDecrypt(row.value, currentKey);
if (decrypted.ok) {
configUpdates.push({ key: row.key, value: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
configDeletes.push(row.key);
}
}
// download_clients JSON (not marked encrypted=true at row level)
const dcRow = await prisma.configuration.findUnique({ where: { key: 'download_clients' } });
if (dcRow && dcRow.value) {
try {
const clients = JSON.parse(dcRow.value);
let touched = 0;
let cleared = 0;
if (Array.isArray(clients)) {
for (const client of clients) {
if (!client || !client.password) continue;
const decrypted = tryDecrypt(client.password, currentKey);
if (decrypted.ok) {
client.password = encryptWithKey(decrypted.value, newKeyBuffer);
touched++;
} else {
client.password = '';
cleared++;
}
}
downloadClientsUpdate = { value: JSON.stringify(clients), touched, cleared };
}
} catch (err) {
console.log(` WARNING: download_clients JSON unparseable, leaving as-is: ${err.message}`);
}
}
// User auth tokens (except the chosen user, whose token will be overwritten)
const allUsers = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, authToken: true, authProvider: true },
});
for (const u of allUsers) {
if (u.id === chosenUser.id) continue;
if (!u.authToken) continue;
const decrypted = tryDecrypt(u.authToken, currentKey);
if (decrypted.ok) {
userUpdates.push({ id: u.id, authToken: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
userUpdates.push({ id: u.id, authToken: '' });
}
}
// Chosen user — fresh bcrypt encrypted with new key
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, newKeyBuffer);
// Apply atomically
summary = await prisma.$transaction(async (tx) => {
const result = {
configRotated: configUpdates.length,
configDeleted: configDeletes.length,
downloadClients: downloadClientsUpdate
? { touched: downloadClientsUpdate.touched, cleared: downloadClientsUpdate.cleared }
: null,
usersRotated: 0,
usersCleared: 0,
};
for (const u of configUpdates) {
await tx.configuration.update({ where: { key: u.key }, data: { value: u.value } });
}
for (const key of configDeletes) {
await tx.configuration.delete({ where: { key } });
}
if (downloadClientsUpdate) {
await tx.configuration.update({
where: { key: 'download_clients' },
data: { value: downloadClientsUpdate.value },
});
}
for (const u of userUpdates) {
await tx.user.update({ where: { id: u.id }, data: { authToken: u.authToken } });
if (u.authToken === '') result.usersCleared++;
else result.usersRotated++;
}
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
return result;
});
} else {
// Simple password reset, current key preserved
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, currentKey);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
});
summary = null;
}
// -------------------------------------------------------------------------
// Post-commit: file writes (only on full recovery)
// -------------------------------------------------------------------------
let fileWriteFailed = false;
if (fullRecovery) {
try {
updateKeyInFile(SECRETS_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, true);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${SECRETS_FILE}: ${err.message}`);
}
try {
updateKeyInFile(ENVIRONMENT_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, false);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${ENVIRONMENT_FILE}: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
console.log('');
console.log('================================================================');
console.log(' Recovery complete.');
console.log('================================================================');
console.log('');
console.log(` User reset: ${chosenUser.plexUsername}`);
if (fullRecovery && summary) {
console.log(` Configuration rows re-encrypted: ${summary.configRotated}`);
console.log(` Configuration rows deleted: ${summary.configDeleted}`);
if (summary.downloadClients) {
console.log(` download_clients passwords re-encrypted: ${summary.downloadClients.touched}`);
console.log(` download_clients passwords cleared: ${summary.downloadClients.cleared}`);
}
console.log(` User tokens re-encrypted: ${summary.usersRotated}`);
console.log(` User tokens cleared: ${summary.usersCleared}`);
console.log('');
if (fileWriteFailed) {
console.log(' ⚠️ Could not persist the new key to .secrets / /etc/environment.');
console.log(' ⚠️ The new key is printed ONCE below. Write it into /app/config/.secrets:');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY="${newKeyBase64}"`);
console.log('');
console.log(' ⚠️ And into /etc/environment (without quotes):');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY=${newKeyBase64}`);
console.log('');
} else {
console.log(' New CONFIG_ENCRYPTION_KEY persisted to /app/config/.secrets and /etc/environment.');
}
console.log('');
console.log(' NEXT STEPS:');
console.log(' 1. Restart the container.');
console.log(` 2. Log in as "${chosenUser.plexUsername}" with the new password.`);
console.log(' 3. Re-enter cleared credentials in Settings (Plex, Prowlarr, etc.).');
} else {
console.log(' Encryption key was healthy — only the password was reset.');
console.log(` Log in as "${chosenUser.plexUsername}" with the new password. No restart needed.`);
}
console.log('');
} catch (err) {
console.error('');
console.error('ERROR: Recovery aborted.');
console.error(` ${err.message}`);
console.error('');
const msg = String(err && err.message ? err.message : '');
if (
msg.includes('was denied access') ||
msg.includes('P1010') ||
msg.includes('password authentication')
) {
console.error('Diagnosis: Postgres rejected the credentials in DATABASE_URL.');
console.error('This usually means /etc/environment or .secrets drifted from what the running');
console.error('app process is actually using (common after a container restart where .secrets');
console.error('was regenerated but the existing Postgres user kept its original password).');
console.error('');
console.error('Try one of:');
console.error(' 1. Restart the container so the entrypoint resyncs all env files, then re-run.');
console.error(' 2. Pass DATABASE_URL explicitly:');
console.error(' docker exec -it \\');
console.error(" -e DATABASE_URL=\"$(docker exec <container> cat /proc/1/environ \\");
console.error(" | tr '\\0' '\\n' | grep ^DATABASE_URL= | cut -d= -f2-)\" \\");
console.error(' <container> npm run rmab:recover');
}
console.error('');
console.error('No changes have been committed (or the DB transaction was rolled back).');
process.exitCode = 1;
} finally {
try {
await prisma.$disconnect();
} catch (_e) {
// ignore
}
}
}
main();
@@ -12,6 +12,8 @@ import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
export interface RequestActionsDropdownProps {
request: {
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
const isAwaitingApproval = request.status === 'awaiting_approval';
// Determine request type
const isEbook = request.type === 'ebook';
@@ -66,7 +72,7 @@ export function RequestActionsDropdown({
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status);
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -157,18 +163,21 @@ export function RequestActionsDropdown({
}
};
const handleCancel = async () => {
const handleCancel = () => {
setIsOpen(false);
const statusNote = request.status === 'awaiting_approval'
? ' It is pending admin approval and will be withdrawn.'
: ' It has already been approved and is actively being processed/monitored.';
const message = `Are you sure you want to cancel this request?${statusNote}`;
if (window.confirm(message)) {
try {
await onCancel(request.requestId);
} catch (error) {
console.error('Failed to cancel request:', error);
}
setConfirmCancelOpen(true);
};
const handleConfirmCancel = async () => {
setIsCancelling(true);
try {
await onCancel(request.requestId);
setConfirmCancelOpen(false);
} catch (error) {
console.error('Failed to cancel request:', error);
setConfirmCancelOpen(false);
} finally {
setIsCancelling(false);
}
};
@@ -533,6 +542,22 @@ export function RequestActionsDropdown({
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isCancelling && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.`
: `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.`
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isCancelling}
/>
</>
);
}
+4 -3
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { Prisma } from '@/generated/prisma/client';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.RequestById');
@@ -134,8 +136,7 @@ export async function PATCH(
}
if (action === 'cancel') {
const cancellableStatuses = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'];
if (!cancellableStatuses.includes(requestRecord.status)) {
if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
@@ -152,7 +153,7 @@ export async function PATCH(
data: {
status: 'cancelled',
updatedAt: new Date(),
...(isAwaitingApproval && { selectedTorrent: null as any }),
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
},
include: {
audiobook: true,
+31 -15
View File
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { usePreferences } from '@/contexts/PreferencesContext';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
interface RequestCardProps {
request: {
@@ -45,26 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const [showError, setShowError] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const [coverError, setCoverError] = React.useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false);
const isAwaitingApproval = request.status === 'awaiting_approval';
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status);
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const handleCancel = async () => {
const statusNote = request.status === 'awaiting_approval'
? ' It is pending admin approval and will be withdrawn.'
: ' It has already been approved and is actively being processed/monitored.';
const message = `Are you sure you want to cancel this request?${statusNote}`;
if (window.confirm(message)) {
try {
await cancelRequest(request.id);
} catch (error) {
console.error('Failed to cancel request:', error);
}
const handleConfirmCancel = async () => {
try {
await cancelRequest(request.id);
setConfirmCancelOpen(false);
} catch (error) {
console.error('Failed to cancel request:', error);
setConfirmCancelOpen(false);
}
};
@@ -232,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
<div className="flex flex-wrap gap-2">
{canCancel && (
<Button
onClick={handleCancel}
onClick={() => setConfirmCancelOpen(true)}
loading={isLoading}
variant="outline"
size="sm"
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Cancel
{isAwaitingApproval ? 'Withdraw' : 'Cancel'}
</Button>
)}
</div>
@@ -258,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
hideRequestActions
/>
)}
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isLoading && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? 'This request is pending admin approval and will be withdrawn. You can request it again later.'
: 'This request has already been approved and is actively being processed. Cancelling will stop the download.'
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isLoading}
/>
</div>
);
}
+9
View File
@@ -5,3 +5,12 @@
/** Terminal statuses indicating a request has been fulfilled and files are ready */
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
/** Statuses from which a request can be cancelled (server-enforced and UI-gated) */
export const CANCELLABLE_STATUSES = [
'pending',
'searching',
'downloading',
'awaiting_search',
'awaiting_approval',
] as const;
+90 -36
View File
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
private username: string;
private password: string;
private cookie?: string;
private authOptional: boolean;
private defaultSavePath: string;
private defaultCategory: string;
private disableSSLVerify: boolean;
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.authOptional = !username && !password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (this.authOptional) {
logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)');
}
// Create HTTPS agent if SSL verification is disabled
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({
@@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient {
}
/**
* Authenticate and establish session
* Build request headers including the session cookie when one exists.
* In auth-optional mode no cookie is set and the Cookie header is omitted.
*/
private authHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
/**
* Authenticate and establish session.
* In auth-optional mode (no username/password configured) this is a no-op.
*/
async login(): Promise<void> {
if (this.authOptional) {
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
return;
}
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
logger.debug('[QBittorrent] Attempting login', {
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
}
// Ensure we're authenticated
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating once if we get a 403
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
// In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted),
// so retrying login is pointless and would mask the real error.
if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) {
logger.info('[QBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options, true);
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
const response = await this.client.post('/torrents/add', form, {
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
});
@@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient {
const response = await this.client.post('/torrents/add', formData, {
headers: {
Cookie: this.cookie,
...this.authHeaders(),
...formData.getHeaders(),
},
maxBodyLength: Infinity,
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
*/
protected async ensureCategory(category: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
try {
// First, get all categories to check if it exists and what save path it has
const categoriesResponse = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
const categories = categoriesResponse.data;
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
* Get torrent status and progress
*/
async getTorrent(hash: string): Promise<TorrentInfo> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params: { hashes: hash },
});
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
* Get all torrents (optionally filtered by category)
*/
async getTorrents(category?: string): Promise<TorrentInfo[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
}
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params,
});
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
* Pause torrent
*/
async pauseTorrent(hash: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
* Resume torrent
*/
async resumeTorrent(hash: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
* Delete torrent
*/
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
* Get files in torrent
*/
async getFiles(hash: string): Promise<TorrentFile[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/files', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params: { hash },
});
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
* Get all configured categories from qBittorrent
*/
async getCategories(): Promise<string[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
return Object.keys(response.data || {});
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
* Set category for torrent
*/
async setCategory(hash: string, category: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient {
}
/**
* Test connection to qBittorrent
* Test connection to qBittorrent.
* In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed.
* In credentialed mode login() is the connectivity check and version is best-effort.
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
await this.login(); // no-op when authOptional; throws on real auth failure
// Fetch version after successful login
let version: string | undefined;
try {
const versionResponse = await this.client.get('/app/version', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
const raw = versionResponse.data || '';
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
} catch {
// Version fetch is non-critical - connection is still valid
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (versionError) {
if (this.authOptional) {
// No login happened — version probe was our only connectivity signal.
const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined;
const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed';
const message = status === 401 || status === 403
? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.`
: `Failed to reach qBittorrent: ${baseMessage}`;
logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage });
return { success: false, message };
}
// Credentialed path: login already succeeded, version is nice-to-have.
logger.debug('Could not fetch qBittorrent version');
return { success: true, message: 'Connected to qBittorrent' };
}
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
logger.error('Connection test failed', { error: message });
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
): Promise<string> {
const baseUrl = url.replace(/\/$/, '');
const loginUrl = `${baseUrl}/api/v2/auth/login`;
const authOptional = !username && !password;
// Create HTTPS agent if SSL verification is disabled
let httpsAgent: https.Agent | undefined;
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
passwordLength: password?.length,
sslVerifyDisabled: disableSSLVerify,
hasHttpsAgent: !!httpsAgent,
authOptional,
});
try {
if (authOptional) {
// No credentials provided — skip /auth/login and probe /app/version directly.
// Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr).
logger.info('[QBittorrent] No credentials provided, probing /app/version directly');
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
httpsAgent,
timeout: DOWNLOAD_CLIENT_TIMEOUT,
});
logger.info('[QBittorrent] Auth-optional version check successful', {
version: versionResponse.data,
});
const rawVersion = versionResponse.data || '';
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
}
const requestBody = new URLSearchParams({ username, password });
const requestHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
// HTTP status errors
if (status === 401 || status === 403) {
if (authOptional) {
throw new Error(
`qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.`
);
}
throw new Error(
`Authentication failed (HTTP ${status}). Check your username and password.`
);
+8
View File
@@ -118,6 +118,14 @@ export async function tagAudioFileMetadata(
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
}
if (metadata.series) {
args.push('-metadata', `SERIES="${escapeMetadata(metadata.series)}"`);
}
if (metadata.seriesPart) {
args.push('-metadata', `SERIES-PART="${escapeMetadata(metadata.seriesPart)}"`);
}
// Explicitly specify output format
args.push('-f', 'flac');
}
+2 -1
View File
@@ -4,6 +4,7 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Prisma } from '@/generated/prisma/client';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
@@ -162,7 +163,7 @@ describe('Request by ID API routes', () => {
expect(payload.request.status).toBe('cancelled');
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ selectedTorrent: null }),
data: expect.objectContaining({ selectedTorrent: Prisma.DbNull }),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
@@ -71,6 +71,7 @@ describe('RequestActionsDropdown', () => {
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Cancel Request'));
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
@@ -103,6 +103,7 @@ describe('RequestCard', () => {
render(<RequestCard request={baseRequest} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
await waitFor(() => {
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
});
@@ -1217,4 +1217,124 @@ describe('QBittorrentService', () => {
expect(result.success).toBe(true);
expect(loginSpy).toHaveBeenCalled();
});
describe('auth-optional mode (blank credentials)', () => {
it('flags service as auth-optional when both credentials are blank', () => {
const service = new QBittorrentService('http://qb', '', '');
expect((service as any).authOptional).toBe(true);
});
it('flags service as credentialed when any credential is provided', () => {
const withUser = new QBittorrentService('http://qb', 'user', '');
const withPass = new QBittorrentService('http://qb', '', 'pass');
expect((withUser as any).authOptional).toBe(false);
expect((withPass as any).authOptional).toBe(false);
});
it('login() is a no-op when auth-optional', async () => {
const service = new QBittorrentService('http://qb', '', '');
await service.login();
expect(axiosMock.post).not.toHaveBeenCalled();
expect((service as any).cookie).toBeUndefined();
});
it('testConnection() succeeds when /app/version returns a version (auth-optional)', async () => {
const service = new QBittorrentService('http://qb', '', '');
clientMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.version).toBe('4.6.0');
expect(axiosMock.post).not.toHaveBeenCalled();
expect(clientMock.get).toHaveBeenCalledWith('/app/version', expect.objectContaining({
headers: {},
}));
});
it('testConnection() returns failure when /app/version returns 401 (auth-optional)', async () => {
const service = new QBittorrentService('http://qb', '', '');
clientMock.get.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: 'Unauthorized',
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toMatch(/requires authentication/i);
});
it('testConnection() returns failure when /app/version is unreachable (auth-optional)', async () => {
const service = new QBittorrentService('http://qb', '', '');
clientMock.get.mockRejectedValueOnce({
isAxiosError: true,
code: 'ECONNREFUSED',
message: 'connect ECONNREFUSED',
});
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.message).toMatch(/Failed to reach qBittorrent/i);
});
it('testConnectionWithCredentials() probes /app/version directly when both creds blank', async () => {
axiosMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', '', '');
expect(version).toBe('4.6.0');
expect(axiosMock.post).not.toHaveBeenCalled();
expect(axiosMock.get).toHaveBeenCalledWith(
'http://qb/api/v2/app/version',
expect.objectContaining({ httpsAgent: undefined })
);
});
it('testConnectionWithCredentials() reports auth-required when blank creds get 401', async () => {
axiosMock.get.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: 'Unauthorized',
config: { url: 'http://qb/api/v2/app/version' },
});
await expect(
QBittorrentService.testConnectionWithCredentials('http://qb', '', '')
).rejects.toThrow(/requires authentication/i);
});
it('addTorrent does not attempt re-login on 403 when auth-optional', async () => {
const service = new QBittorrentService('http://qb', '', '');
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
const loginSpy = vi.spyOn(service, 'login');
vi.spyOn(service as any, 'addMagnetLink').mockRejectedValueOnce({
isAxiosError: true,
response: { status: 403 },
});
await expect(
service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
).rejects.toThrow('Failed to add torrent');
expect(loginSpy).not.toHaveBeenCalled();
});
it('omits Cookie header on requests when auth-optional', async () => {
const service = new QBittorrentService('http://qb', '', '');
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
clientMock.post.mockResolvedValue({ data: 'Ok.' });
await (service as any).addMagnetLink(
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
'readmeabook'
);
const headers = clientMock.post.mock.calls[0][2].headers;
expect(headers.Cookie).toBeUndefined();
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded');
});
});
});
@@ -125,6 +125,72 @@ describe('processPlexRecentlyAddedCheck', () => {
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
});
it('persists durations exceeding INT4 max as BigInt on both create and update paths (regression for #193)', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getMany.mockResolvedValue({
plex_url: 'http://plex',
plex_token: 'token',
plex_audiobook_library_id: 'lib-1',
});
configMock.get.mockResolvedValue('lib-1');
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
backendBaseUrl: 'http://plex',
authToken: 'token',
backendMode: 'plex',
});
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue(null);
// Production-observed overflow value: ~4_082_750 seconds → 4_082_750_000 ms (> INT4 max 2_147_483_647)
const overflowSeconds = 4_082_750;
const overflowMs = BigInt(overflowSeconds * 1000);
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
{
id: 'rating-new',
externalId: 'guid-new',
title: 'Long Audiobook (new)',
author: 'Author',
duration: overflowSeconds,
addedAt: new Date(),
},
{
id: 'rating-existing',
externalId: 'guid-existing',
title: 'Long Audiobook (existing)',
author: 'Author',
duration: overflowSeconds,
addedAt: new Date(),
},
]);
prismaMock.plexLibrary.findUnique.mockImplementation(async (query: any) => {
if (query.where.plexGuid === 'guid-existing') {
return { id: 'existing-id', plexGuid: 'guid-existing', author: 'Author', duration: null };
}
return null;
});
prismaMock.plexLibrary.create.mockResolvedValue({});
prismaMock.plexLibrary.update.mockResolvedValue({});
prismaMock.request.findMany.mockResolvedValue([]);
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
await processPlexRecentlyAddedCheck({ jobId: 'job-overflow' });
expect(prismaMock.plexLibrary.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ duration: overflowMs }),
})
);
expect(prismaMock.plexLibrary.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { plexGuid: 'guid-existing' },
data: expect.objectContaining({ duration: overflowMs }),
})
);
});
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
const matcher = await import('@/lib/utils/audiobook-matcher');
const absApi = await import('@/lib/services/audiobookshelf/api');
@@ -140,6 +140,79 @@ describe('processScanPlex', () => {
);
});
it('persists durations exceeding INT4 max as BigInt on both create and update paths (regression for #193)', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
libraryId: 'lib-1',
machineIdentifier: 'machine',
});
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
backendBaseUrl: 'http://plex',
authToken: 'token',
backendMode: 'plex',
});
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue(null);
// Production-observed overflow value: ~4_082_750 seconds → 4_082_750_000 ms (> INT4 max 2_147_483_647)
const overflowSeconds = 4_082_750;
const overflowMs = BigInt(overflowSeconds * 1000);
libraryServiceMock.getLibraryItems.mockResolvedValue([
{
id: 'rating-new',
externalId: 'guid-new',
title: 'Long Audiobook (new)',
author: 'Author',
duration: overflowSeconds,
addedAt: new Date(),
updatedAt: new Date(),
},
{
id: 'rating-existing',
externalId: 'guid-existing',
title: 'Long Audiobook (existing)',
author: 'Author',
duration: overflowSeconds,
addedAt: new Date(),
updatedAt: new Date(),
},
]);
prismaMock.plexLibrary.findFirst.mockImplementation(async (query: any) => {
if (query.where.plexGuid === 'guid-existing') {
return { id: 'existing-id', plexGuid: 'guid-existing', author: 'Author', duration: null };
}
return null;
});
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-new' });
prismaMock.plexLibrary.update.mockResolvedValue({});
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
prismaMock.audiobook.findMany.mockResolvedValue([]);
prismaMock.request.findMany.mockResolvedValue([]);
const matcher = await import('@/lib/utils/audiobook-matcher');
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
await processScanPlex({ jobId: 'job-overflow' });
expect(prismaMock.plexLibrary.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ duration: overflowMs }),
})
);
expect(prismaMock.plexLibrary.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'existing-id' },
data: expect.objectContaining({ duration: overflowMs }),
})
);
});
it('throws when audiobookshelf library is not configured', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue(null);
+66
View File
@@ -114,6 +114,72 @@ describe('metadata tagger', () => {
await expect(checkFfmpegAvailable()).resolves.toBe(false);
});
describe('series metadata', () => {
it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '1',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata show="The Mistborn Saga"');
expect(command).toContain('-metadata episode_id="1"');
});
it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.mp3', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '1.5',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
expect(command).toContain('-metadata SERIES-PART="1.5"');
});
it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.flac', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '2',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
expect(command).toContain('-metadata SERIES-PART="2"');
});
it('omits series tags when fields are absent', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).not.toContain('show=');
expect(command).not.toContain('episode_id=');
expect(command).not.toContain('SERIES=');
expect(command).not.toContain('SERIES-PART=');
});
});
describe('metadata escaping', () => {
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
fsMock.access.mockResolvedValue(undefined);