mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add rootless Podman fixes, and others
improve container startup for rootless Podman, plus related refactors and tests. Key changes: - Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation. - Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification. - Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests. - Update many admin/auth API routes and tests to reflect changes in settings and integrations. - Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow. These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
This commit is contained in:
@@ -1,357 +0,0 @@
|
||||
# AGENTS.md - Project Standards & Workflow
|
||||
|
||||
**Critical:** This document defines AI-optimized documentation standards and development workflow.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Efficient Documentation System
|
||||
|
||||
### Why Token Efficiency Matters
|
||||
|
||||
**Problem:** Documentation consumes significant token budget, leaving limited context for implementation.
|
||||
|
||||
**Solution:** Documentation optimized for AI consumption, not human reading. Average 68-72% token reduction while preserving all technical details.
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
```
|
||||
documentation/
|
||||
├── TABLEOFCONTENTS.md # Navigation index (read THIS FIRST)
|
||||
├── README.md # Project overview
|
||||
├── backend/
|
||||
│ ├── database.md # Schema, Prisma, migrations
|
||||
│ └── services/
|
||||
│ ├── auth.md # Plex OAuth, JWT, RBAC
|
||||
│ ├── config.md # Settings, encryption
|
||||
│ ├── jobs.md # Bull queue, processors
|
||||
│ └── scheduler.md # Cron jobs, recurring tasks
|
||||
├── integrations/
|
||||
│ ├── plex.md # Library scanning, OAuth, matching
|
||||
│ └── audible.md # Web scraping, metadata
|
||||
├── phase3/ # Automation pipeline
|
||||
│ ├── README.md # Pipeline overview
|
||||
│ ├── qbittorrent.md # Download client
|
||||
│ ├── prowlarr.md # Indexer search
|
||||
│ ├── ranking-algorithm.md # Torrent selection
|
||||
│ └── file-organization.md # File management, seeding
|
||||
├── frontend/
|
||||
│ ├── components.md # React components
|
||||
│ ├── routing-auth.md # Route protection
|
||||
│ └── pages/
|
||||
│ └── login.md # Login page design
|
||||
├── deployment/
|
||||
│ └── docker.md # Docker Compose, volumes
|
||||
└── [feature-specific docs]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Using TABLEOFCONTENTS.md (MANDATORY)
|
||||
|
||||
### **RULE: Always Start Here**
|
||||
|
||||
**Before reading ANY documentation:**
|
||||
1. **Read `documentation/TABLEOFCONTENTS.md` FIRST**
|
||||
2. Identify relevant sections for your task
|
||||
3. Read ONLY the specific files you need
|
||||
4. **Never read all files sequentially** (wastes tokens)
|
||||
|
||||
### Example Workflow
|
||||
|
||||
**Bad (Token wasteful):**
|
||||
```
|
||||
Task: Fix Plex authentication
|
||||
❌ Read README.md → backend/* → integrations/* → ...
|
||||
```
|
||||
|
||||
**Good (Token efficient):**
|
||||
```
|
||||
Task: Fix Plex authentication
|
||||
✅ Read TABLEOFCONTENTS.md → Identify: backend/services/auth.md, integrations/plex.md
|
||||
✅ Read only those 2 files
|
||||
✅ Begin implementation
|
||||
```
|
||||
|
||||
### TABLEOFCONTENTS.md Format
|
||||
|
||||
Maps questions/features to specific documentation files:
|
||||
- "How does authentication work?" → backend/services/auth.md
|
||||
- "How do downloads work?" → phase3/qbittorrent.md, backend/services/jobs.md
|
||||
- Organized by: Authentication, Configuration, Database, Integrations, Automation, etc.
|
||||
|
||||
---
|
||||
|
||||
## 3. Token-Efficient Documentation Format
|
||||
|
||||
### Mandatory Format Standards
|
||||
|
||||
**All documentation MUST follow this token-optimized format:**
|
||||
|
||||
#### Structure
|
||||
```markdown
|
||||
# [Title]
|
||||
|
||||
**Status:** [✅ Implemented / ⏳ In Progress / ❌ Not Started] [Brief description]
|
||||
|
||||
## Overview
|
||||
[1-2 sentence summary]
|
||||
|
||||
## Key Details
|
||||
- Compact bullet lists (not prose)
|
||||
- API endpoints with request/response
|
||||
- Data models with field names/types
|
||||
- Configuration keys
|
||||
- Critical implementation notes
|
||||
|
||||
## API/Interfaces
|
||||
[Tables or compact code blocks]
|
||||
|
||||
## Critical Issues (if any)
|
||||
[Only important items]
|
||||
|
||||
## Related: [links to other docs]
|
||||
```
|
||||
|
||||
#### Forbidden Content (Removed for Token Efficiency)
|
||||
- ❌ Verbose prose explanations
|
||||
- ❌ "Why?" sections (keep brief rationale only)
|
||||
- ❌ Large ASCII diagrams (minimal only)
|
||||
- ❌ Excessive examples (max 1-2)
|
||||
- ❌ "Future Enhancements" sections
|
||||
- ❌ "Testing Strategy" (unless critical)
|
||||
- ❌ "Performance Considerations" (unless critical)
|
||||
- ❌ Empty sections
|
||||
- ❌ Decorative formatting
|
||||
|
||||
#### Required Content (Preserve Completely)
|
||||
- ✅ API endpoint definitions
|
||||
- ✅ Data model field names and types
|
||||
- ✅ Configuration keys and values
|
||||
- ✅ Status values and enums
|
||||
- ✅ File paths and code locations
|
||||
- ✅ Critical implementation details
|
||||
- ✅ "Fixed Issues" (troubleshooting context)
|
||||
- ✅ Essential code examples (1-2 max)
|
||||
|
||||
### Format Examples
|
||||
|
||||
**Before (Token wasteful - 180 lines):**
|
||||
```markdown
|
||||
# User Authentication Service
|
||||
|
||||
## Current State
|
||||
|
||||
**Status:** Implemented ✅
|
||||
|
||||
This service handles all authentication and authorization logic for the
|
||||
ReadMeABook application, including Plex OAuth integration, JWT session
|
||||
management, and role-based access control.
|
||||
|
||||
## Design Architecture
|
||||
|
||||
### Why Plex OAuth?
|
||||
|
||||
Plex OAuth was chosen for several important reasons:
|
||||
- No need to manage passwords
|
||||
- Users already have Plex accounts
|
||||
- Seamless integration with Plex ecosystem
|
||||
...
|
||||
[continues for 150+ more lines]
|
||||
```
|
||||
|
||||
**After (Token efficient - 50 lines):**
|
||||
```markdown
|
||||
# User Authentication Service
|
||||
|
||||
**Status:** ✅ Implemented | Plex OAuth + JWT sessions + RBAC
|
||||
|
||||
## Overview
|
||||
Handles Plex OAuth, JWT session management, role-based access control (user/admin).
|
||||
|
||||
## Key Details
|
||||
- **Auth:** Plex OAuth flow → JWT tokens (access: 1h, refresh: 7d)
|
||||
- **Roles:** user (requests only), admin (full access)
|
||||
- **First user:** Auto-promoted to admin
|
||||
- **Endpoints:**
|
||||
- POST /api/auth/plex/login → {authUrl, pinId}
|
||||
- GET /api/auth/plex/callback?pinId → {accessToken, refreshToken, user}
|
||||
- POST /api/auth/refresh → {accessToken}
|
||||
- GET /api/auth/me → {user}
|
||||
- **Middleware:** requireAuth(), requireAdmin()
|
||||
- **Storage:** HTTP-only cookies + localStorage
|
||||
|
||||
## JWT Payload
|
||||
```json
|
||||
{
|
||||
"sub": "user-uuid",
|
||||
"plexId": "plex-id",
|
||||
"role": "admin",
|
||||
"exp": 1234571490
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## 4. Implementation Strategy
|
||||
|
||||
### Step 1: Navigate with TABLEOFCONTENTS.md
|
||||
- Read TABLEOFCONTENTS.md to find relevant docs
|
||||
- Identify 1-3 specific files needed (not all docs)
|
||||
|
||||
### Step 2: Read Minimal Context
|
||||
- Read ONLY the identified files
|
||||
- Focus on "Key Details" and "API/Interfaces" sections
|
||||
- Skip examples unless implementing similar functionality
|
||||
|
||||
### Step 3: Reiterate Understanding
|
||||
- Brief paragraph (3-4 sentences max)
|
||||
- What user wants, what's affected, expected outcome
|
||||
|
||||
### Step 4: Create Implementation Plan (TodoWrite)
|
||||
```
|
||||
- [ ] Read: [specific doc files]
|
||||
- [ ] Update: [specific doc files]
|
||||
- [ ] Implement: [specific changes]
|
||||
- [ ] Verify: [test steps]
|
||||
```
|
||||
|
||||
### Step 5: Implement
|
||||
- Follow plan
|
||||
- Update docs using token-efficient format
|
||||
- Add file headers linking to docs
|
||||
|
||||
---
|
||||
|
||||
## 5. Documentation Maintenance
|
||||
|
||||
### **RULE: Update TABLEOFCONTENTS.md**
|
||||
|
||||
**When adding new documentation:**
|
||||
1. Create doc file using token-efficient format
|
||||
2. **Update TABLEOFCONTENTS.md** with new mapping
|
||||
3. Update parent README.md if needed
|
||||
|
||||
**Example:**
|
||||
```markdown
|
||||
# Added new feature: Email notifications
|
||||
|
||||
Files created:
|
||||
- documentation/backend/services/notifications.md
|
||||
|
||||
Updates required:
|
||||
- ✅ Create notifications.md (token-efficient format)
|
||||
- ✅ Add to TABLEOFCONTENTS.md: "Email notifications" → backend/services/notifications.md
|
||||
- ✅ Update documentation/README.md → Backend section
|
||||
```
|
||||
|
||||
### **RULE: Keep Docs Up-to-Date**
|
||||
- **Before code changes:** Read relevant docs
|
||||
- **After code changes:** Update docs immediately
|
||||
- Use token-efficient format for all updates
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Standards
|
||||
|
||||
### File Size Limits
|
||||
- Max 300-400 lines per file
|
||||
- Refactor if exceeding limit
|
||||
|
||||
### Mandatory File Headers
|
||||
```typescript
|
||||
/**
|
||||
* Component: User Authentication Service
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
```
|
||||
|
||||
### Link Accuracy
|
||||
- Header path MUST point to existing doc file
|
||||
- Create doc BEFORE implementing code
|
||||
- Use relative paths from project root
|
||||
|
||||
---
|
||||
|
||||
## 7. Token Budget Management
|
||||
|
||||
### Critical Principle
|
||||
|
||||
**Preserve tokens for implementation, not context gathering.**
|
||||
|
||||
**Token Budget Allocation:**
|
||||
- 20-30%: Reading relevant documentation (via TABLEOFCONTENTS.md)
|
||||
- 70-80%: Implementation, problem-solving, code generation
|
||||
|
||||
**Anti-Patterns (Token wasteful):**
|
||||
- ❌ Reading all documentation files
|
||||
- ❌ Reading verbose examples when not needed
|
||||
- ❌ Re-reading same docs multiple times
|
||||
- ❌ Reading "Future Enhancements" sections
|
||||
|
||||
**Best Practices (Token efficient):**
|
||||
- ✅ Use TABLEOFCONTENTS.md to target specific files
|
||||
- ✅ Read only "Key Details" and "API/Interfaces" sections
|
||||
- ✅ Skip examples unless implementing similar code
|
||||
- ✅ Cache understanding in memory, don't re-read
|
||||
|
||||
---
|
||||
|
||||
## 8. Examples
|
||||
|
||||
### Example 1: Bug Fix
|
||||
|
||||
**Task:** "Plex authentication fails with 403 error"
|
||||
|
||||
**Process:**
|
||||
1. Read TABLEOFCONTENTS.md → Find: backend/services/auth.md, integrations/plex.md
|
||||
2. Read only those 2 files (focus on API endpoints, error handling)
|
||||
3. Identify issue: Token refresh logic
|
||||
4. Fix code
|
||||
5. Update backend/services/auth.md (token-efficient format)
|
||||
|
||||
### Example 2: New Feature
|
||||
|
||||
**Task:** "Add email notifications for completed requests"
|
||||
|
||||
**Process:**
|
||||
1. Read TABLEOFCONTENTS.md → Find: backend/services/scheduler.md, backend/services/jobs.md
|
||||
2. Read those files for background job patterns
|
||||
3. Create documentation/backend/services/notifications.md (token-efficient format)
|
||||
4. Update TABLEOFCONTENTS.md: "Email notifications" → backend/services/notifications.md
|
||||
5. Implement notification service
|
||||
6. Add file header linking to notifications.md
|
||||
|
||||
---
|
||||
|
||||
## 9. Quality Checklist
|
||||
|
||||
Before completing any task:
|
||||
|
||||
- [ ] Used TABLEOFCONTENTS.md to find docs (not read all files)
|
||||
- [ ] Read only necessary documentation
|
||||
- [ ] Updated documentation in token-efficient format
|
||||
- [ ] Updated TABLEOFCONTENTS.md if added new docs
|
||||
- [ ] Added file headers to new code files
|
||||
- [ ] No file exceeds 400 lines
|
||||
- [ ] Documentation matches implementation
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary
|
||||
|
||||
**Key Points:**
|
||||
1. **Always start with TABLEOFCONTENTS.md** (navigation index)
|
||||
2. **Read only what you need** (not all docs)
|
||||
3. **Use token-efficient format** (bullets, tables, minimal prose)
|
||||
4. **Preserve tokens for implementation** (not context gathering)
|
||||
5. **Update docs immediately** (before/after code changes)
|
||||
6. **Update TABLEOFCONTENTS.md** (when adding new docs)
|
||||
|
||||
**Result:**
|
||||
- 68-72% token reduction in documentation
|
||||
- Faster context gathering
|
||||
- More tokens available for implementation
|
||||
- Better AI performance on complex tasks
|
||||
|
||||
---
|
||||
|
||||
**Remember:** Documentation is for AI consumption. Token efficiency is critical. Always use TABLEOFCONTENTS.md.
|
||||
@@ -91,10 +91,24 @@ Feature and fix Contributions are highly welcome. Documentation in `documentatio
|
||||
|
||||
## Support
|
||||
|
||||
If you find this project useful, consider supporting development via [GitHub Sponsors]()
|
||||
If you find this project useful, consider supporting development via [GitHub Sponsors](https://github.com/sponsors/kikootwo) or [Ko-fi](https://ko-fi.com/kikootwo).
|
||||
|
||||
If you'd like to support but cannot sponsor, a simple star on the GitHub repo is also greatly appreciated!
|
||||
|
||||
## Built with AI Assistance
|
||||
|
||||
This is a human-engineered application. Architecture, design decisions, code review, and project direction are managed by a principal engineer with nearly 15 years of professional software development experience.
|
||||
|
||||
AI tools (Claude, GitHub Copilot) serve as force multipliers. Accelerating implementation, maintaining consistency, and handling boilerplate, while human expertise drives the technical vision. This mirrors how AI assistance is used at leading technology companies today.
|
||||
|
||||
**The workflow:**
|
||||
- Token-optimized documentation system designed for AI consumption ([CLAUDE.md](CLAUDE.md))
|
||||
- Structured navigation enabling AI to find relevant context without reading entire codebases
|
||||
- Consistent architectural patterns that AI tools can follow and extend
|
||||
- Human review of all AI-generated code before merge
|
||||
|
||||
The result: enterprise-grade velocity on a solo project without sacrificing code quality or architectural integrity.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
+83
-26
@@ -1,9 +1,44 @@
|
||||
#!/bin/bash
|
||||
# App startup wrapper for unified container
|
||||
# Uses gosu to ensure correct PUID:PGID for file operations
|
||||
#
|
||||
# Supports:
|
||||
# - Docker: Uses gosu to switch to PUID:PGID
|
||||
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
|
||||
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
|
||||
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# USER NAMESPACE DETECTION
|
||||
# =============================================================================
|
||||
# Detects if running in a user namespace where UID 0 is remapped to a non-root
|
||||
# user on the host (e.g., rootless Podman). In this case, using gosu would
|
||||
# cause a double-mapping that breaks volume permissions.
|
||||
#
|
||||
# How it works:
|
||||
# - /proc/self/uid_map shows the UID mapping for the current namespace
|
||||
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
|
||||
# - In a normal container: "0 0 4294967295" (root maps to root)
|
||||
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
|
||||
#
|
||||
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
|
||||
# =============================================================================
|
||||
is_user_namespace_root() {
|
||||
if [ -f /proc/self/uid_map ]; then
|
||||
# Read the first mapping line (covers UID 0)
|
||||
read -r inside outside count < /proc/self/uid_map
|
||||
# Trim whitespace (uid_map has leading spaces for alignment)
|
||||
inside=$(echo "$inside" | xargs)
|
||||
outside=$(echo "$outside" | xargs)
|
||||
# If UID 0 inside maps to non-0 outside, we're in a user namespace
|
||||
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
|
||||
return 0 # true - rootless container detected
|
||||
fi
|
||||
fi
|
||||
return 1 # false - normal container (Docker or rootful Podman)
|
||||
}
|
||||
|
||||
# Load environment from /etc/environment (set by entrypoint)
|
||||
if [ -f /etc/environment ]; then
|
||||
set -a
|
||||
@@ -20,40 +55,62 @@ echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
cd /app
|
||||
|
||||
# Use gosu to switch to correct UID:GID and start server
|
||||
# This bypasses username resolution issues when PUID collides with existing users
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Running as root - use gosu to switch to PUID:PGID
|
||||
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||
# =============================================================================
|
||||
# START SERVER WITH APPROPRIATE UID:GID HANDLING
|
||||
# =============================================================================
|
||||
# Three scenarios:
|
||||
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
|
||||
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
|
||||
# 3. Non-root fallback: Already running as non-root, run directly
|
||||
|
||||
# Start server in background with gosu
|
||||
gosu "$PUID:$PGID" node server.js &
|
||||
SERVER_PID=$!
|
||||
start_server() {
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if is_user_namespace_root; then
|
||||
# Rootless container (e.g., rootless Podman)
|
||||
# Skip gosu - the user namespace already maps our "root" to the correct host UID
|
||||
echo "[App] Detected rootless container (user namespace with remapped root)"
|
||||
echo "[App] Skipping gosu to preserve user namespace UID mapping"
|
||||
echo "[App] Process will run as namespace UID 0 (mapped to host user)"
|
||||
node server.js &
|
||||
else
|
||||
# Normal container (Docker or rootful Podman)
|
||||
# Use gosu to switch to the specified PUID:PGID
|
||||
echo "[App] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||
gosu "$PUID:$PGID" node server.js &
|
||||
fi
|
||||
else
|
||||
# Not running as root - run directly (fallback for unusual configurations)
|
||||
echo "[App] Warning: Not running as root, cannot use gosu. Running as current user ($(id -u):$(id -g))."
|
||||
node server.js &
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[App] Waiting for server to be ready..."
|
||||
sleep 5
|
||||
# Start the server in background
|
||||
start_server
|
||||
SERVER_PID=$!
|
||||
|
||||
# Initialize application services (creates default scheduled jobs)
|
||||
echo "[App] Initializing application services..."
|
||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
||||
echo "[App] Waiting for server to be ready..."
|
||||
sleep 5
|
||||
|
||||
echo "[App] Server ready with PID $SERVER_PID (running as $PUID:$PGID)"
|
||||
# Initialize application services (creates default scheduled jobs)
|
||||
echo "[App] Initializing application services..."
|
||||
curl -sf http://localhost:3030/api/init || echo "[App] Warning: Failed to initialize services (may already be initialized)"
|
||||
|
||||
# Verify the process is running with correct UID:GID
|
||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||
ACTUAL_UID=$(grep '^Uid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
||||
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
||||
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
|
||||
echo "[App] Server ready with PID $SERVER_PID"
|
||||
|
||||
# Verify the process is running with correct UID:GID (for debugging)
|
||||
if [ -f "/proc/$SERVER_PID/status" ]; then
|
||||
ACTUAL_UID=$(grep '^Uid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
||||
ACTUAL_GID=$(grep '^Gid:' /proc/$SERVER_PID/status | awk '{print $2}')
|
||||
echo "[App] Verified process credentials: UID=$ACTUAL_UID GID=$ACTUAL_GID"
|
||||
|
||||
# Only warn about mismatch in non-rootless scenarios
|
||||
if ! is_user_namespace_root; then
|
||||
if [ "$ACTUAL_UID" != "$PUID" ] || [ "$ACTUAL_GID" != "$PGID" ]; then
|
||||
echo "[App] WARNING: Process UID:GID ($ACTUAL_UID:$ACTUAL_GID) does not match expected ($PUID:$PGID)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for server process
|
||||
wait $SERVER_PID
|
||||
else
|
||||
# Not running as root - just run directly (fallback)
|
||||
echo "[App] Warning: Not running as root, cannot use gosu. Running as current user."
|
||||
exec node server.js
|
||||
fi
|
||||
|
||||
# Wait for server process (keeps the script running as long as the server is alive)
|
||||
wait $SERVER_PID
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Redis startup wrapper for unified container
|
||||
# Uses gosu to ensure correct PUID:PGID for file operations
|
||||
#
|
||||
# Supports:
|
||||
# - Docker: Uses gosu to switch to PUID:PGID
|
||||
# - Rootful Podman: Uses gosu to switch to PUID:PGID (same as Docker)
|
||||
# - Rootless Podman: Skips gosu to preserve user namespace UID mapping
|
||||
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# USER NAMESPACE DETECTION
|
||||
# =============================================================================
|
||||
# Detects if running in a user namespace where UID 0 is remapped to a non-root
|
||||
# user on the host (e.g., rootless Podman). In this case, using gosu would
|
||||
# cause a double-mapping that breaks volume permissions.
|
||||
#
|
||||
# How it works:
|
||||
# - /proc/self/uid_map shows the UID mapping for the current namespace
|
||||
# - Format: <uid-inside-ns> <uid-outside-ns> <range>
|
||||
# - In a normal container: "0 0 4294967295" (root maps to root)
|
||||
# - In rootless Podman: "0 1000 1" (root maps to host user 1000)
|
||||
#
|
||||
# Returns 0 (true) if in a user namespace with remapped root, 1 (false) otherwise
|
||||
# =============================================================================
|
||||
is_user_namespace_root() {
|
||||
if [ -f /proc/self/uid_map ]; then
|
||||
# Read the first mapping line (covers UID 0)
|
||||
read -r inside outside count < /proc/self/uid_map
|
||||
# Trim whitespace (uid_map has leading spaces for alignment)
|
||||
inside=$(echo "$inside" | xargs)
|
||||
outside=$(echo "$outside" | xargs)
|
||||
# If UID 0 inside maps to non-0 outside, we're in a user namespace
|
||||
if [ "$inside" = "0" ] && [ "$outside" != "0" ]; then
|
||||
return 0 # true - rootless container detected
|
||||
fi
|
||||
fi
|
||||
return 1 # false - normal container (Docker or rootful Podman)
|
||||
}
|
||||
|
||||
# Load environment from /etc/environment (set by entrypoint)
|
||||
if [ -f /etc/environment ]; then
|
||||
set -a
|
||||
@@ -18,14 +53,32 @@ PGID=${PGID:-$(id -g redis)}
|
||||
echo "[Redis] Starting Redis server..."
|
||||
echo "[Redis] Process will run as UID:GID = $PUID:$PGID"
|
||||
|
||||
# Use gosu to switch to correct UID:GID and start redis
|
||||
# This bypasses username resolution issues when PUID collides with existing users
|
||||
# =============================================================================
|
||||
# START REDIS WITH APPROPRIATE UID:GID HANDLING
|
||||
# =============================================================================
|
||||
# Three scenarios:
|
||||
# 1. Docker / Rootful Podman: Running as root, use gosu to switch to PUID:PGID
|
||||
# 2. Rootless Podman: Running as "root" in user namespace, skip gosu to preserve mapping
|
||||
# 3. Non-root fallback: Already running as non-root, run directly
|
||||
|
||||
REDIS_CMD="/usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379"
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Running as root - use gosu to switch to PUID:PGID
|
||||
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||
exec gosu "$PUID:$PGID" /usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379
|
||||
if is_user_namespace_root; then
|
||||
# Rootless container (e.g., rootless Podman)
|
||||
# Skip gosu - the user namespace already maps our "root" to the correct host UID
|
||||
echo "[Redis] Detected rootless container (user namespace with remapped root)"
|
||||
echo "[Redis] Skipping gosu to preserve user namespace UID mapping"
|
||||
echo "[Redis] Process will run as namespace UID 0 (mapped to host user)"
|
||||
exec $REDIS_CMD
|
||||
else
|
||||
# Normal container (Docker or rootful Podman)
|
||||
# Use gosu to switch to the specified PUID:PGID
|
||||
echo "[Redis] Switching to UID:GID $PUID:$PGID via gosu..."
|
||||
exec gosu "$PUID:$PGID" $REDIS_CMD
|
||||
fi
|
||||
else
|
||||
# Not running as root - just run directly (fallback)
|
||||
echo "[Redis] Warning: Not running as root, cannot use gosu. Running as current user."
|
||||
exec /usr/bin/redis-server --appendonly yes --dir /var/lib/redis --bind 127.0.0.1 --port 6379
|
||||
# Not running as root - run directly (fallback for unusual configurations)
|
||||
echo "[Redis] Warning: Not running as root, cannot use gosu. Running as current user ($(id -u):$(id -g))."
|
||||
exec $REDIS_CMD
|
||||
fi
|
||||
|
||||
@@ -25,13 +25,12 @@
|
||||
- **OAuth, library scanning, fuzzy matching** → [integrations/plex.md](integrations/plex.md)
|
||||
- **Availability status, plexGuid linking** → [integrations/plex.md](integrations/plex.md)
|
||||
|
||||
## Audiobookshelf Integration (PRD - Not Implemented)
|
||||
- **Full PRD, architecture, implementation phases** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
|
||||
- **Step-by-step implementation guide** → [features/audiobookshelf-implementation-guide.md](features/audiobookshelf-implementation-guide.md)
|
||||
- **OIDC authentication (Authentik, Keycloak)** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
|
||||
- **Manual user registration** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
|
||||
- **Backend mode selection (Plex vs ABS)** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
|
||||
- **Library service abstraction** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md)
|
||||
## Audiobookshelf Integration
|
||||
- **ABS API client, library scanning** → `src/lib/services/audiobookshelf/api.ts`
|
||||
- **ABS library service** → `src/lib/services/library/AudiobookshelfLibraryService.ts`
|
||||
- **Backend mode selection (Plex vs ABS)** → [backend/services/config.md](backend/services/config.md)
|
||||
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
||||
|
||||
## Audible Integration
|
||||
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
||||
@@ -138,10 +137,9 @@
|
||||
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
|
||||
**"How does logging work?"** → [backend/services/logging.md](backend/services/logging.md)
|
||||
**"How do BookDate card stack animations work?"** → [features/bookdate-animations.md](features/bookdate-animations.md)
|
||||
**"How does Audiobookshelf integration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
|
||||
**"How do I use OIDC/Authentik/Keycloak?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
|
||||
**"How does manual user registration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
|
||||
**"How do I switch from Plex to Audiobookshelf?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
|
||||
**"How does Audiobookshelf integration work?"** → `src/lib/services/audiobookshelf/api.ts`, `src/lib/services/library/AudiobookshelfLibraryService.ts`
|
||||
**"How do I use OIDC/Authentik/Keycloak?"** → [backend/services/auth.md](backend/services/auth.md)
|
||||
**"How do I switch from Plex to Audiobookshelf?"** → Setup wizard (re-run setup with different backend mode)
|
||||
**"How does library thumbnail caching work?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
|
||||
@@ -194,6 +194,6 @@ environment:
|
||||
## Related
|
||||
|
||||
- OAuth Implementation: documentation/backend/services/auth.md
|
||||
- OIDC Configuration: documentation/features/audiobookshelf-integration.md
|
||||
- OIDC Configuration: documentation/backend/services/auth.md
|
||||
- Deployment: documentation/deployment/unified.md
|
||||
- Setup Middleware: documentation/backend/middleware.md
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -322,7 +322,7 @@ Plex (After Fix):
|
||||
## Related Documentation
|
||||
|
||||
- [Database Schema](../backend/database.md) - Updated with Plex_Library table
|
||||
- [Audiobookshelf Integration](../features/audiobookshelf-integration.md) - Full backend integration docs
|
||||
- [File Hash Matching](file-hash-matching.md) - ASIN matching via file hash for ABS
|
||||
- [Plex Integration](../integrations/plex.md) - Plex-specific matching details
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
@@ -243,13 +243,20 @@ organizePath = PathMapper.transform(sabPath, config)
|
||||
- When enabled, NZB downloads are automatically cleaned up after files are organized
|
||||
- Saves disk space by removing completed download files
|
||||
|
||||
**Two-Stage Cleanup Process:**
|
||||
1. **Filesystem Cleanup:** Manually deletes download directory/files using `fs.rm()`
|
||||
**Three-Stage Cleanup Process:**
|
||||
1. **Download Cleanup:** Deletes download directory/files using `fs.rm()`
|
||||
- Removes extracted files from category download directory
|
||||
- Handles both single files and directories recursively
|
||||
- Gracefully handles already-deleted files (ENOENT)
|
||||
|
||||
2. **SABnzbd Archive:** Archives NZB from history (hides from UI)
|
||||
2. **Parent Directory Cleanup:** Removes empty parent directories up to `download_dir`
|
||||
- Uses `removeEmptyParentDirectories()` utility from `cleanup-helpers.ts`
|
||||
- Walks up directory tree removing empty folders (e.g., empty category folder)
|
||||
- Never deletes `download_dir` itself (protected boundary)
|
||||
- Stops at first non-empty directory
|
||||
- Gracefully handles permission errors and race conditions
|
||||
|
||||
3. **SABnzbd Archive:** Archives NZB from history (hides from UI)
|
||||
- Uses SABnzbd's archive feature (default: `archive=1`)
|
||||
- Preserves job in hidden archive for troubleshooting/auditing
|
||||
- Does NOT permanently delete from history
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.ID');
|
||||
@@ -97,10 +98,15 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
// Update clients array
|
||||
// Update clients array and encrypt passwords before saving
|
||||
clients[clientIndex] = updatedClient;
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedClients = clients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(clients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
@@ -153,10 +159,15 @@ export async function DELETE(
|
||||
|
||||
const deletedClient = clients[clientIndex];
|
||||
|
||||
// Remove client from array
|
||||
// Remove client from array and encrypt passwords before saving
|
||||
const updatedClients = clients.filter(c => c.id !== id);
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedClients = updatedClients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@@ -111,7 +112,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Create new client config
|
||||
// Create new client config for testing (with plaintext password)
|
||||
// qBittorrent credentials are optional (supports IP whitelist auth)
|
||||
const newClient: DownloadClientConfig = {
|
||||
id: randomUUID(),
|
||||
@@ -120,7 +121,7 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
url,
|
||||
username: username || '',
|
||||
password: password || '',
|
||||
password: password || '', // Plaintext for connection test
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||
remotePath: remotePath || undefined,
|
||||
@@ -137,10 +138,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt all passwords before saving (existing clients come decrypted from getAllClients)
|
||||
const encryptionService = getEncryptionService();
|
||||
const allClients = [...existingClients, newClient];
|
||||
const encryptedClients = allClients.map(c => ({
|
||||
...c,
|
||||
password: c.password ? encryptionService.encrypt(c.password) : '',
|
||||
}));
|
||||
|
||||
// Save updated clients array
|
||||
const updatedClients = [...existingClients, newClient];
|
||||
await config.setMany([
|
||||
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
|
||||
{ key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Paths');
|
||||
@@ -84,6 +85,14 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
const configService = getConfigService();
|
||||
configService.clearCache('download_dir');
|
||||
configService.clearCache('media_dir');
|
||||
configService.clearCache('audiobook_path_template');
|
||||
configService.clearCache('metadata_tagging_enabled');
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
|
||||
// Invalidate qBittorrent service singleton to force reload of download_dir
|
||||
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
|
||||
invalidateQBittorrentService();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.AdminPlexSettings');
|
||||
@@ -33,10 +34,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Only update token if it's not the masked value
|
||||
if (!token.startsWith('••••')) {
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedToken = encryptionService.encrypt(token);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: token },
|
||||
create: { key: 'plex_token', value: token },
|
||||
update: { value: encryptedToken, encrypted: true },
|
||||
create: { key: 'plex_token', value: encryptedToken, encrypted: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,10 +62,10 @@ export async function PUT(request: NextRequest) {
|
||||
const plexService = getPlexService();
|
||||
const actualToken = token.startsWith('••••') ? null : token;
|
||||
|
||||
// Get token from DB if it was masked
|
||||
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
}))?.value;
|
||||
// Get token from DB if it was masked (decrypted via ConfigService)
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const tokenToUse = actualToken || await configService.get('plex_token');
|
||||
|
||||
if (tokenToUse) {
|
||||
const serverInfo = await plexService.testConnection(url, tokenToUse);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
||||
@@ -32,10 +33,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Only update API key if it's not the masked value
|
||||
if (!apiKey.startsWith('••••')) {
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedApiKey = encryptionService.encrypt(apiKey);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: apiKey },
|
||||
create: { key: 'prowlarr_api_key', value: apiKey },
|
||||
update: { value: encryptedApiKey, encrypted: true },
|
||||
create: { key: 'prowlarr_api_key', value: encryptedApiKey, encrypted: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -43,21 +44,24 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If password is masked, fetch the actual value from database
|
||||
// If password is masked, fetch the actual value from download client manager (decrypted)
|
||||
let actualPassword = password;
|
||||
if (password && password.startsWith('••••')) {
|
||||
const storedPassword = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_client_password' },
|
||||
});
|
||||
if (password && (password.startsWith('••••') || password === '********')) {
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const clients = await manager.getAllClients();
|
||||
|
||||
if (!storedPassword?.value) {
|
||||
// Find the first client of matching type to get its password
|
||||
const matchingClient = clients.find(c => c.type === type);
|
||||
|
||||
if (!matchingClient?.password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored password/API key found. Please re-enter it.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualPassword = storedPassword.value;
|
||||
actualPassword = matchingClient.password;
|
||||
}
|
||||
|
||||
// Validate required fields per client type and test connection
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If token is masked, fetch the actual value from database
|
||||
// If token is masked, fetch the actual value from database (decrypted)
|
||||
let actualToken = token;
|
||||
if (token.startsWith('••••')) {
|
||||
const storedToken = await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
});
|
||||
const configService = getConfigService();
|
||||
const storedToken = await configService.get('plex_token');
|
||||
|
||||
if (!storedToken?.value) {
|
||||
if (!storedToken) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualToken = storedToken.value;
|
||||
actualToken = storedToken;
|
||||
}
|
||||
|
||||
const plexService = getPlexService();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,21 +24,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// If API key is masked, fetch the actual value from database
|
||||
// If API key is masked, fetch the actual value from database (decrypted)
|
||||
let actualApiKey = apiKey;
|
||||
if (apiKey.startsWith('••••')) {
|
||||
const storedApiKey = await prisma.configuration.findUnique({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
});
|
||||
const configService = getConfigService();
|
||||
const storedApiKey = await configService.get('prowlarr_api_key');
|
||||
|
||||
if (!storedApiKey?.value) {
|
||||
if (!storedApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualApiKey = storedApiKey.value;
|
||||
actualApiKey = storedApiKey;
|
||||
}
|
||||
|
||||
// Create a new ProwlarrService instance with test credentials
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -149,12 +150,19 @@ export async function GET(request: NextRequest) {
|
||||
if (homeUsers.length > 1) {
|
||||
logger.info('Account has multiple home profiles, redirecting to profile selection');
|
||||
|
||||
// SECURITY: Store the Plex token server-side instead of exposing it to the client
|
||||
// The token is keyed by pinId and will be retrieved during profile selection/switch
|
||||
const tokenCache = getAuthTokenCache();
|
||||
tokenCache.set(pinId, authToken);
|
||||
logger.debug('Plex token cached for profile selection', { pinId });
|
||||
|
||||
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
|
||||
const accept = request.headers.get('accept') || '';
|
||||
const isBrowserRequest = accept.includes('text/html');
|
||||
|
||||
if (isBrowserRequest) {
|
||||
// For browser requests (mobile), construct redirect URL with session data
|
||||
// For browser requests (mobile), construct redirect URL
|
||||
// Token is stored server-side, only pinId is passed to client
|
||||
const host = request.headers.get('host') || 'localhost:3030';
|
||||
const protocol = request.headers.get('x-forwarded-proto') ||
|
||||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||
@@ -162,7 +170,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
logger.debug('Redirecting to profile selection', { selectProfileUrl });
|
||||
|
||||
// Return HTML page with JavaScript to store token in sessionStorage and redirect
|
||||
// Return HTML page that redirects to profile selection
|
||||
// Note: Token is NOT included - it's stored server-side for security
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -172,9 +181,7 @@ export async function GET(request: NextRequest) {
|
||||
<body>
|
||||
<p>Loading profiles...</p>
|
||||
<script>
|
||||
// Store main account token in session storage for profile selection page
|
||||
sessionStorage.setItem('plex_main_token', '${authToken}');
|
||||
// Redirect to profile selection
|
||||
// Redirect to profile selection (token is stored server-side)
|
||||
window.location.href = '${selectProfileUrl}';
|
||||
</script>
|
||||
</body>
|
||||
@@ -189,12 +196,12 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} else {
|
||||
// For AJAX requests (desktop popup), return JSON with redirect instruction
|
||||
// Note: Token is NOT included - it's stored server-side for security
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
authorized: true,
|
||||
requiresProfileSelection: true,
|
||||
redirectUrl: `/auth/select-profile?pinId=${pinId}`,
|
||||
mainAccountToken: authToken, // Client will store this temporarily
|
||||
homeUsers: homeUsers.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
|
||||
@@ -12,16 +13,36 @@ const logger = RMABLogger.create('API.Auth.Plex.HomeUsers');
|
||||
/**
|
||||
* GET /api/auth/plex/home-users
|
||||
* Get list of Plex Home profiles for authenticated user
|
||||
*
|
||||
* Authentication: Provide X-Plex-Pin-Id header with the PIN ID from OAuth flow.
|
||||
* The Plex token is retrieved from server-side cache for security.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authToken = request.headers.get('X-Plex-Token');
|
||||
// Get pinId from header - token is stored server-side for security
|
||||
const pinId = request.headers.get('X-Plex-Pin-Id');
|
||||
|
||||
if (!authToken) {
|
||||
if (!pinId) {
|
||||
logger.warn('Missing X-Plex-Pin-Id header');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Missing authentication token',
|
||||
message: 'Missing PIN ID. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieve the Plex token from server-side cache
|
||||
const tokenCache = getAuthTokenCache();
|
||||
const authToken = tokenCache.get(pinId);
|
||||
|
||||
if (!authToken) {
|
||||
logger.warn('Token not found or expired for pinId', { pinId });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'SessionExpired',
|
||||
message: 'Your session has expired. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
@@ -30,6 +51,8 @@ export async function GET(request: NextRequest) {
|
||||
const plexService = getPlexService();
|
||||
const users = await plexService.getHomeUsers(authToken);
|
||||
|
||||
logger.debug('Home users retrieved', { pinId, userCount: users.length });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { getAuthTokenCache } from '@/lib/services/auth-token-cache.service';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
@@ -15,23 +16,41 @@ const logger = RMABLogger.create('API.PlexSwitchProfile');
|
||||
/**
|
||||
* POST /api/auth/plex/switch-profile
|
||||
* Switch to a Plex Home profile and complete authentication
|
||||
*
|
||||
* Authentication: Provide pinId in request body. The Plex token is
|
||||
* retrieved from server-side cache for security.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const mainAccountToken = request.headers.get('X-Plex-Token');
|
||||
const body = await request.json();
|
||||
const { userId, pin, pinId, profileInfo } = body;
|
||||
|
||||
if (!mainAccountToken) {
|
||||
// Validate pinId is provided
|
||||
if (!pinId) {
|
||||
logger.warn('Missing pinId in request body');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Missing authentication token',
|
||||
message: 'Missing PIN ID. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { userId, pin, pinId, profileInfo } = body;
|
||||
// Retrieve the Plex token from server-side cache
|
||||
const tokenCache = getAuthTokenCache();
|
||||
const mainAccountToken = tokenCache.get(pinId);
|
||||
|
||||
if (!mainAccountToken) {
|
||||
logger.warn('Token not found or expired for pinId', { pinId });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'SessionExpired',
|
||||
message: 'Your session has expired. Please restart the login process.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
@@ -155,6 +174,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
// Clean up the cached Plex token - authentication is complete
|
||||
tokenCache.delete(pinId);
|
||||
logger.debug('Cached Plex token cleaned up after successful auth', { pinId });
|
||||
|
||||
// Return tokens and user info
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
import { runCredentialMigration } from '@/lib/services/credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Init');
|
||||
@@ -18,6 +19,9 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
logger.info('Initializing application services...');
|
||||
|
||||
// Run credential migration (encrypts any plaintext credentials)
|
||||
await runCredentialMigration();
|
||||
|
||||
// Initialize scheduler service
|
||||
const schedulerService = getSchedulerService();
|
||||
await schedulerService.start();
|
||||
|
||||
@@ -193,10 +193,11 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'plex_url', value: plex.url },
|
||||
});
|
||||
|
||||
const encryptedPlexToken = encryptionService.encrypt(plex.token);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: plex.token },
|
||||
create: { key: 'plex_token', value: plex.token },
|
||||
update: { value: encryptedPlexToken, encrypted: true },
|
||||
create: { key: 'plex_token', value: encryptedPlexToken, encrypted: true },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
@@ -375,10 +376,11 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'prowlarr_url', value: prowlarr.url },
|
||||
});
|
||||
|
||||
const encryptedProwlarrApiKey = encryptionService.encrypt(prowlarr.api_key);
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: prowlarr.api_key },
|
||||
create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
|
||||
update: { value: encryptedProwlarrApiKey, encrypted: true },
|
||||
create: { key: 'prowlarr_api_key', value: encryptedProwlarrApiKey, encrypted: true },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
@@ -414,10 +416,16 @@ export async function POST(request: NextRequest) {
|
||||
throw new Error('Invalid download client configuration');
|
||||
}
|
||||
|
||||
// Encrypt passwords in download clients
|
||||
const encryptedClients = downloadClientsArray.map(client => ({
|
||||
...client,
|
||||
password: client.password ? encryptionService.encrypt(client.password) : '',
|
||||
}));
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_clients' },
|
||||
update: { value: JSON.stringify(downloadClientsArray) },
|
||||
create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) },
|
||||
update: { value: JSON.stringify(encryptedClients) },
|
||||
create: { key: 'download_clients', value: JSON.stringify(encryptedClients) },
|
||||
});
|
||||
|
||||
// Path configuration
|
||||
|
||||
@@ -38,42 +38,45 @@ function SelectProfileContent() {
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Get token from session storage (set by OAuth callback)
|
||||
const mainAccountToken = typeof window !== 'undefined' ? sessionStorage.getItem('plex_main_token') : null;
|
||||
// Get pinId from URL - the Plex token is stored server-side for security
|
||||
const pinId = searchParams.get('pinId');
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainAccountToken || !pinId) {
|
||||
if (!pinId) {
|
||||
setError('Invalid session. Please try logging in again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch home users
|
||||
// Fetch home users using pinId (token is looked up server-side)
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/plex/home-users', {
|
||||
headers: {
|
||||
'X-Plex-Token': mainAccountToken,
|
||||
'X-Plex-Pin-Id': pinId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profiles');
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
throw new Error(errorData.message || 'Session expired. Please try logging in again.');
|
||||
}
|
||||
throw new Error(errorData.message || 'Failed to fetch profiles');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfiles(data.users || []);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch profiles:', err);
|
||||
setError('Failed to load profiles. Please try again.');
|
||||
setError(err.message || 'Failed to load profiles. Please try again.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfiles();
|
||||
}, [mainAccountToken, pinId]);
|
||||
}, [pinId]);
|
||||
|
||||
const handleProfileSelect = async (profile: PlexHomeUser) => {
|
||||
setSelectedProfile(profile.id);
|
||||
@@ -97,7 +100,10 @@ function SelectProfileContent() {
|
||||
};
|
||||
|
||||
const completeProfileSelection = async (profileId: string, profilePin?: string) => {
|
||||
if (!mainAccountToken) return;
|
||||
if (!pinId) {
|
||||
setError('Session expired. Please try logging in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setPinError(null);
|
||||
@@ -111,11 +117,11 @@ function SelectProfileContent() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch profile using pinId - token is looked up server-side for security
|
||||
const response = await fetch('/api/auth/plex/switch-profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Plex-Token': mainAccountToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: profileId,
|
||||
@@ -134,8 +140,15 @@ function SelectProfileContent() {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
setPinError('Invalid PIN. Please try again.');
|
||||
setPin('');
|
||||
// Check if it's a PIN error or session expiry
|
||||
if (data.error === 'InvalidPIN') {
|
||||
setPinError('Invalid PIN. Please try again.');
|
||||
setPin('');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// Session expired
|
||||
setError(data.message || 'Session expired. Please try logging in again.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -150,9 +163,6 @@ function SelectProfileContent() {
|
||||
// Update auth context
|
||||
setAuthData(data.user, data.accessToken);
|
||||
|
||||
// Clear session storage
|
||||
sessionStorage.removeItem('plex_main_token');
|
||||
|
||||
// Redirect to home
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -173,10 +173,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (data.success && data.authorized) {
|
||||
// Check if profile selection is required (Plex Home accounts)
|
||||
if (data.requiresProfileSelection) {
|
||||
// Store main account token temporarily for profile selection
|
||||
sessionStorage.setItem('plex_main_token', data.mainAccountToken);
|
||||
|
||||
// Redirect to profile selection page
|
||||
// Note: Plex token is stored server-side for security, not in sessionStorage
|
||||
window.location.href = data.redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1027,8 +1027,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
pathMappingEnabled: clientConfig.remotePathMappingEnabled,
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
|
||||
// Validate required fields (only URL is required - username/password optional for whitelist users)
|
||||
if (!clientConfig.url) {
|
||||
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
|
||||
}
|
||||
|
||||
@@ -1045,8 +1045,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
logger.info('[QBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
clientConfig.url,
|
||||
clientConfig.username,
|
||||
clientConfig.password,
|
||||
clientConfig.username || '',
|
||||
clientConfig.password || '',
|
||||
downloadDir,
|
||||
clientConfig.category || 'readmeabook',
|
||||
clientConfig.disableSSLVerify,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -296,6 +297,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
@@ -776,6 +789,18 @@ async function processEbookOrganization(
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
|
||||
// Clean up empty parent directories (e.g., empty category folders)
|
||||
// Get download_dir as the boundary - never delete above this
|
||||
const downloadDir = await configService.get('download_dir') || '/downloads';
|
||||
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
|
||||
boundaryPath: downloadDir,
|
||||
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
|
||||
});
|
||||
|
||||
if (cleanupResult.removedDirectories.length > 0) {
|
||||
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Component: Audiobookshelf API Client
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*
|
||||
* Provides API methods for interacting with Audiobookshelf:
|
||||
* - Library scanning and item fetching
|
||||
* - Metadata matching (with ASIN for accurate Audible lookup)
|
||||
* - Item management
|
||||
*/
|
||||
|
||||
import { getConfigService } from '../config.service';
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Component: Auth Token Cache Service
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*
|
||||
* Provides secure server-side storage for Plex OAuth tokens during the
|
||||
* profile selection flow. Tokens are stored in memory with automatic
|
||||
* expiration to prevent sensitive data from being exposed in client responses.
|
||||
*
|
||||
* Security: This service exists to prevent Plex tokens from being embedded
|
||||
* in HTML responses or JSON payloads where they could be captured by
|
||||
* viewing page source or intercepting network traffic.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('AuthTokenCache');
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TTL for cached tokens (5 minutes)
|
||||
* This is sufficient time for profile selection while minimizing exposure window
|
||||
*/
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cleanup interval - run every minute to remove expired tokens
|
||||
*/
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* AuthTokenCacheService - Singleton service for secure token storage
|
||||
*
|
||||
* Uses an in-memory Map for storage. Tokens are automatically expired
|
||||
* and cleaned up. This is intentionally ephemeral - if the server restarts,
|
||||
* users in the middle of profile selection will need to re-authenticate,
|
||||
* which is acceptable for security.
|
||||
*/
|
||||
class AuthTokenCacheService {
|
||||
private cache: Map<string, CachedToken> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private ttlMs: number;
|
||||
|
||||
constructor(ttlMs: number = DEFAULT_TTL_MS) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a Plex token for later retrieval
|
||||
*
|
||||
* @param pinId - The Plex PIN ID (used as the lookup key)
|
||||
* @param token - The Plex OAuth token to store
|
||||
* @param ttlMs - Optional custom TTL for this token
|
||||
*/
|
||||
set(pinId: string, token: string, ttlMs?: number): void {
|
||||
const effectiveTtl = ttlMs ?? this.ttlMs;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.set(pinId, {
|
||||
token,
|
||||
createdAt: now,
|
||||
expiresAt: now + effectiveTtl,
|
||||
});
|
||||
|
||||
logger.debug('Token cached', {
|
||||
pinId,
|
||||
ttlSeconds: Math.round(effectiveTtl / 1000),
|
||||
cacheSize: this.cache.size,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a stored token by PIN ID
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns The stored token, or null if not found/expired
|
||||
*/
|
||||
get(pinId: string): string | null {
|
||||
const cached = this.cache.get(pinId);
|
||||
|
||||
if (!cached) {
|
||||
logger.debug('Token not found in cache', { pinId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
logger.debug('Token expired', { pinId });
|
||||
this.cache.delete(pinId);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('Token retrieved from cache', { pinId });
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token from the cache
|
||||
* Called after successful authentication to clean up
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns true if a token was removed, false if not found
|
||||
*/
|
||||
delete(pinId: string): boolean {
|
||||
const existed = this.cache.has(pinId);
|
||||
this.cache.delete(pinId);
|
||||
|
||||
if (existed) {
|
||||
logger.debug('Token removed from cache', { pinId, cacheSize: this.cache.size });
|
||||
}
|
||||
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token exists and is not expired
|
||||
*
|
||||
* @param pinId - The Plex PIN ID
|
||||
* @returns true if token exists and is valid
|
||||
*/
|
||||
has(pinId: string): boolean {
|
||||
const cached = this.cache.get(pinId);
|
||||
if (!cached) return false;
|
||||
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
this.cache.delete(pinId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache size (for monitoring)
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger cleanup of expired tokens
|
||||
* Called automatically on interval, but can be called manually if needed
|
||||
*/
|
||||
cleanup(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [pinId, cached] of this.cache.entries()) {
|
||||
if (now > cached.expiresAt) {
|
||||
this.cache.delete(pinId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
logger.debug('Expired tokens cleaned up', { removed, remaining: this.cache.size });
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached tokens
|
||||
* Use with caution - will force all users in profile selection to re-authenticate
|
||||
*/
|
||||
clear(): void {
|
||||
const count = this.cache.size;
|
||||
this.cache.clear();
|
||||
logger.info('Token cache cleared', { tokensRemoved: count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the automatic cleanup interval
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
// Don't start multiple intervals
|
||||
if (this.cleanupInterval) return;
|
||||
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Don't prevent Node.js from exiting
|
||||
if (this.cleanupInterval.unref) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
|
||||
logger.debug('Cleanup interval started', { intervalMs: CLEANUP_INTERVAL_MS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup interval (for testing or shutdown)
|
||||
*/
|
||||
stopCleanupInterval(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
logger.debug('Cleanup interval stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: AuthTokenCacheService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton AuthTokenCacheService instance
|
||||
*/
|
||||
export function getAuthTokenCache(): AuthTokenCacheService {
|
||||
if (!instance) {
|
||||
instance = new AuthTokenCacheService();
|
||||
logger.info('Auth token cache initialized');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing only)
|
||||
*/
|
||||
export function resetAuthTokenCache(): void {
|
||||
if (instance) {
|
||||
instance.stopCleanupInterval();
|
||||
instance.clear();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthTokenCacheService };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Component: Credential Migration Service
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*
|
||||
* One-time migration to encrypt plaintext credentials stored in the database.
|
||||
* Runs on startup and auto-detects plaintext vs encrypted values.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('CredentialMigration');
|
||||
|
||||
/**
|
||||
* Check if a value looks like it's already encrypted.
|
||||
* Encrypted values have format: base64:base64:base64 (iv:authTag:ciphertext)
|
||||
*/
|
||||
export function isEncryptedFormat(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all parts look like base64
|
||||
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
||||
return parts.every(part => part.length > 0 && base64Regex.test(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single configuration key from plaintext to encrypted.
|
||||
* Returns true if migration was performed, false if already encrypted or not found.
|
||||
*/
|
||||
async function migrateConfigKey(key: string): Promise<boolean> {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
if (!config || !config.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if already marked as encrypted
|
||||
if (config.encrypted) {
|
||||
logger.debug(`Key "${key}" already marked as encrypted, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if value looks like it's already encrypted (format check)
|
||||
if (isEncryptedFormat(config.value)) {
|
||||
logger.debug(`Key "${key}" appears to be in encrypted format, updating flag only`);
|
||||
await prisma.configuration.update({
|
||||
where: { key },
|
||||
data: { encrypted: true },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt the plaintext value
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedValue = encryptionService.encrypt(config.value);
|
||||
|
||||
await prisma.configuration.update({
|
||||
where: { key },
|
||||
data: {
|
||||
value: encryptedValue,
|
||||
encrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Migrated credential: ${key}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate download_clients JSON to encrypt passwords within.
|
||||
* Returns true if any passwords were encrypted.
|
||||
*/
|
||||
async function migrateDownloadClients(): Promise<boolean> {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_clients' },
|
||||
});
|
||||
|
||||
if (!config || !config.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let clients: any[];
|
||||
try {
|
||||
clients = JSON.parse(config.value);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse download_clients JSON', { error });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(clients) || clients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const client of clients) {
|
||||
// Encrypt password if present and not already encrypted
|
||||
if (client.password && typeof client.password === 'string' && !isEncryptedFormat(client.password)) {
|
||||
client.password = encryptionService.encrypt(client.password);
|
||||
migratedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCount > 0) {
|
||||
await prisma.configuration.update({
|
||||
where: { key: 'download_clients' },
|
||||
data: { value: JSON.stringify(clients) },
|
||||
});
|
||||
|
||||
logger.info(`Migrated ${migratedCount} download client password(s)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the credential migration.
|
||||
* Safe to call multiple times - detects and skips already-encrypted values.
|
||||
*/
|
||||
export async function runCredentialMigration(): Promise<void> {
|
||||
logger.info('Starting credential migration check...');
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
// Migrate simple config keys
|
||||
const keysToMigrate = [
|
||||
'plex_token',
|
||||
'prowlarr_api_key',
|
||||
];
|
||||
|
||||
for (const key of keysToMigrate) {
|
||||
try {
|
||||
const migrated = await migrateConfigKey(key);
|
||||
if (migrated) {
|
||||
totalMigrated++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to migrate ${key}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate download client passwords
|
||||
try {
|
||||
const migratedClients = await migrateDownloadClients();
|
||||
if (migratedClients) {
|
||||
totalMigrated++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to migrate download client passwords', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
if (totalMigrated > 0) {
|
||||
logger.info(`Credential migration complete: ${totalMigrated} item(s) encrypted`);
|
||||
} else {
|
||||
logger.info('Credential migration complete: no changes needed');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ConfigurationService } from './config.service';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { isEncryptedFormat } from './credential-migration.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
@@ -86,8 +88,26 @@ export class DownloadClientManager {
|
||||
if (configValue) {
|
||||
try {
|
||||
const clients = JSON.parse(configValue) as DownloadClientConfig[];
|
||||
this.clientsCache = clients;
|
||||
return clients;
|
||||
|
||||
// Decrypt passwords if they're in encrypted format
|
||||
const encryptionService = getEncryptionService();
|
||||
const decryptedClients = clients.map(client => {
|
||||
if (client.password && isEncryptedFormat(client.password)) {
|
||||
try {
|
||||
return {
|
||||
...client,
|
||||
password: encryptionService.decrypt(client.password),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to decrypt password for client ${client.name}`, { error });
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
this.clientsCache = decryptedClients;
|
||||
return decryptedClients;
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse download_clients config', { error });
|
||||
return [];
|
||||
|
||||
@@ -676,9 +676,14 @@ export async function mergeChapters(
|
||||
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
|
||||
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
|
||||
|
||||
// Add book metadata
|
||||
// Add book metadata (escape for double-quoted shell context)
|
||||
// Single quotes do NOT need escaping inside double quotes - they are literal
|
||||
const escapeMetadata = (val: string): string =>
|
||||
val.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
||||
val
|
||||
.replace(/\\/g, '\\\\') // Backslashes first
|
||||
.replace(/"/g, '\\"') // Double quotes
|
||||
.replace(/`/g, '\\`') // Backticks
|
||||
.replace(/\$/g, '\\$'); // Dollar signs
|
||||
|
||||
args.push('-metadata', `title="${escapeMetadata(options.title)}"`);
|
||||
args.push('-metadata', `album="${escapeMetadata(options.title)}"`);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Cleanup Helpers Utility
|
||||
* Documentation: documentation/phase3/sabnzbd.md
|
||||
*
|
||||
* Provides utilities for cleaning up after file organization,
|
||||
* including removal of empty parent directories.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('CleanupHelpers');
|
||||
|
||||
/**
|
||||
* Options for removeEmptyParentDirectories
|
||||
*/
|
||||
export interface RemoveEmptyParentOptions {
|
||||
/** The boundary path - will never delete this directory or its parents */
|
||||
boundaryPath: string;
|
||||
/** Optional logger context for job-aware logging */
|
||||
logContext?: { jobId: string; context: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes empty parent directories after a file/directory has been deleted.
|
||||
*
|
||||
* This function walks up the directory tree from the deleted path, removing
|
||||
* any empty directories until it encounters a non-empty directory or reaches
|
||||
* the configured boundary path.
|
||||
*
|
||||
* Use case: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
|
||||
* After deleting the download folder, the category folder (readmeabook) may
|
||||
* be left empty. This function cleans up those empty parent folders.
|
||||
*
|
||||
* Safety features:
|
||||
* - Will NEVER delete the boundary path itself (e.g., download_dir)
|
||||
* - Will NEVER delete above the boundary path
|
||||
* - Gracefully handles ENOENT (already deleted)
|
||||
* - Gracefully handles permission errors (logs warning, continues)
|
||||
* - Stops immediately when a non-empty directory is encountered
|
||||
*
|
||||
* @param deletedPath - The path that was just deleted (file or directory)
|
||||
* @param options - Configuration options including boundary path
|
||||
* @returns Object with details about what was cleaned up
|
||||
*
|
||||
* @example
|
||||
* // After deleting /downloads/readmeabook/My.Audiobook.Name
|
||||
* await removeEmptyParentDirectories(
|
||||
* '/downloads/readmeabook/My.Audiobook.Name',
|
||||
* { boundaryPath: '/downloads' }
|
||||
* );
|
||||
* // This will remove /downloads/readmeabook if it's empty
|
||||
* // but will never touch /downloads
|
||||
*/
|
||||
export async function removeEmptyParentDirectories(
|
||||
deletedPath: string,
|
||||
options: RemoveEmptyParentOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
removedDirectories: string[];
|
||||
stoppedAt?: string;
|
||||
stoppedReason?: 'non_empty' | 'boundary_reached' | 'root_reached' | 'error';
|
||||
error?: string;
|
||||
}> {
|
||||
const log = options.logContext
|
||||
? RMABLogger.forJob(options.logContext.jobId, options.logContext.context)
|
||||
: logger;
|
||||
|
||||
const removedDirectories: string[] = [];
|
||||
|
||||
try {
|
||||
// Normalize paths for consistent comparison
|
||||
const normalizedBoundary = normalizePath(options.boundaryPath);
|
||||
let currentPath = normalizePath(path.dirname(deletedPath));
|
||||
|
||||
log.debug('Starting empty parent directory cleanup', {
|
||||
deletedPath,
|
||||
boundaryPath: options.boundaryPath,
|
||||
normalizedBoundary,
|
||||
startingFrom: currentPath,
|
||||
});
|
||||
|
||||
// Walk up the directory tree
|
||||
while (true) {
|
||||
// Safety check: Have we reached the filesystem root?
|
||||
const parentPath = normalizePath(path.dirname(currentPath));
|
||||
if (parentPath === currentPath) {
|
||||
log.debug('Reached filesystem root, stopping cleanup');
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'root_reached',
|
||||
};
|
||||
}
|
||||
|
||||
// Safety check: Have we reached or passed the boundary?
|
||||
if (!isPathBelowBoundary(currentPath, normalizedBoundary)) {
|
||||
log.debug('Reached boundary path, stopping cleanup', {
|
||||
currentPath,
|
||||
boundaryPath: normalizedBoundary,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'boundary_reached',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the directory is empty
|
||||
const isEmpty = await isDirectoryEmpty(currentPath);
|
||||
|
||||
if (isEmpty === null) {
|
||||
// Directory doesn't exist (ENOENT) - move to parent
|
||||
log.debug(`Directory does not exist, moving to parent: ${currentPath}`);
|
||||
currentPath = parentPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEmpty) {
|
||||
// Directory is not empty - stop here
|
||||
log.debug(`Directory not empty, stopping cleanup: ${currentPath}`);
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'non_empty',
|
||||
};
|
||||
}
|
||||
|
||||
// Directory is empty - try to remove it
|
||||
try {
|
||||
await fs.rmdir(currentPath);
|
||||
removedDirectories.push(currentPath);
|
||||
log.info(`Removed empty directory: ${currentPath}`);
|
||||
} catch (removeError) {
|
||||
const errorCode = (removeError as NodeJS.ErrnoException).code;
|
||||
|
||||
if (errorCode === 'ENOENT') {
|
||||
// Already deleted (race condition) - continue to parent
|
||||
log.debug(`Directory already deleted: ${currentPath}`);
|
||||
} else if (errorCode === 'ENOTEMPTY') {
|
||||
// Directory became non-empty (race condition) - stop
|
||||
log.debug(`Directory became non-empty: ${currentPath}`);
|
||||
return {
|
||||
success: true,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'non_empty',
|
||||
};
|
||||
} else if (errorCode === 'EACCES' || errorCode === 'EPERM') {
|
||||
// Permission error - log warning and stop
|
||||
log.warn(`Permission denied removing directory: ${currentPath}`, {
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
});
|
||||
return {
|
||||
success: true, // Partial success - we cleaned what we could
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'error',
|
||||
error: `Permission denied: ${currentPath}`,
|
||||
};
|
||||
} else {
|
||||
// Unexpected error - log and stop
|
||||
log.error(`Failed to remove directory: ${currentPath}`, {
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
errorCode,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
removedDirectories,
|
||||
stoppedAt: currentPath,
|
||||
stoppedReason: 'error',
|
||||
error: removeError instanceof Error ? removeError.message : String(removeError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent directory
|
||||
currentPath = parentPath;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Unexpected error during empty parent cleanup', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
deletedPath,
|
||||
boundaryPath: options.boundaryPath,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
removedDirectories,
|
||||
stoppedReason: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a directory is empty
|
||||
*
|
||||
* @param dirPath - Path to the directory
|
||||
* @returns true if empty, false if not empty, null if directory doesn't exist
|
||||
*/
|
||||
async function isDirectoryEmpty(dirPath: string): Promise<boolean | null> {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath);
|
||||
return entries.length === 0;
|
||||
} catch (error) {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (errorCode === 'ENOENT') {
|
||||
// Directory doesn't exist
|
||||
return null;
|
||||
}
|
||||
|
||||
if (errorCode === 'ENOTDIR') {
|
||||
// Path is a file, not a directory
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is strictly below (inside) the boundary path
|
||||
*
|
||||
* A path is below the boundary if:
|
||||
* - It's longer than the boundary path
|
||||
* - It starts with the boundary path followed by a path separator
|
||||
*
|
||||
* @param testPath - The path to test (must be normalized)
|
||||
* @param boundaryPath - The boundary path (must be normalized)
|
||||
* @returns true if testPath is strictly below boundaryPath
|
||||
*/
|
||||
function isPathBelowBoundary(testPath: string, boundaryPath: string): boolean {
|
||||
// Ensure both paths don't have trailing slashes for comparison
|
||||
const normalizedTest = testPath.replace(/\/+$/, '');
|
||||
const normalizedBoundary = boundaryPath.replace(/\/+$/, '');
|
||||
|
||||
// Path must be strictly below boundary, not equal to it
|
||||
if (normalizedTest === normalizedBoundary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if test path is under boundary path
|
||||
// Must start with boundary + separator to avoid matching /downloads2 when boundary is /downloads
|
||||
return normalizedTest.startsWith(normalizedBoundary + '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a file path for consistent comparison
|
||||
*
|
||||
* @param filePath - Path to normalize
|
||||
* @returns Normalized path with forward slashes and no trailing slash
|
||||
*/
|
||||
function normalizePath(filePath: string): string {
|
||||
// Convert backslashes to forward slashes
|
||||
let normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Use path.normalize to handle redundant separators and ..
|
||||
normalized = path.normalize(normalized);
|
||||
|
||||
// Convert backslashes again (path.normalize might add them on Windows)
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root '/')
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -165,16 +165,22 @@ export async function tagMultipleFiles(
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape metadata values for shell command
|
||||
* Removes quotes and special characters that could break the command
|
||||
* Escape metadata values for shell command (double-quoted context)
|
||||
*
|
||||
* In double-quoted shell strings, only these characters need escaping:
|
||||
* - Backslashes (must be first to avoid double-escaping)
|
||||
* - Double quotes
|
||||
* - Backticks (command substitution)
|
||||
* - Dollar signs (variable expansion)
|
||||
*
|
||||
* Single quotes do NOT need escaping inside double quotes - they are literal.
|
||||
*/
|
||||
function escapeMetadata(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes FIRST (before other escapes add backslashes)
|
||||
.replace(/"/g, '\\"') // Escape double quotes
|
||||
.replace(/'/g, "\\'") // Escape single quotes
|
||||
.replace(/`/g, '\\`') // Escape backticks
|
||||
.replace(/\$/g, '\\$') // Escape dollar signs
|
||||
.replace(/\\/g, '\\\\'); // Escape backslashes
|
||||
.replace(/`/g, '\\`') // Escape backticks (prevents command substitution)
|
||||
.replace(/\$/g, '\\$'); // Escape dollar signs (prevents variable expansion)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,13 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
invalidateDownloadClientManager: invalidateDownloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => ({
|
||||
encrypt: (value: string) => `enc-${value}`,
|
||||
decrypt: (value: string) => value.replace('enc-', ''),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Admin settings core routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -30,6 +30,12 @@ const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
constants: { R_OK: 4 },
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getAllClients: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -73,6 +79,14 @@ vi.mock('fs/promises', () => ({
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings test routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -211,7 +225,10 @@ describe('Admin settings test routes', () => {
|
||||
});
|
||||
|
||||
it('uses stored password when masked password is provided', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' });
|
||||
// Mock download client manager to return the stored password
|
||||
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([
|
||||
{ type: 'qbittorrent', password: 'stored-pass' },
|
||||
]);
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
@@ -236,7 +253,8 @@ describe('Admin settings test routes', () => {
|
||||
});
|
||||
|
||||
it('returns error when masked password is missing in storage', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
// Mock download client manager to return no matching client
|
||||
downloadClientManagerMock.getAllClients.mockResolvedValueOnce([]);
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
|
||||
@@ -22,6 +22,11 @@ const encryptionServiceMock = vi.hoisted(() => ({
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const authTokenCacheMock = vi.hoisted(() => ({
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
|
||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
|
||||
|
||||
@@ -41,6 +46,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/auth-token-cache.service', () => ({
|
||||
getAuthTokenCache: () => authTokenCacheMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
generateRefreshToken: generateRefreshTokenMock,
|
||||
@@ -194,8 +203,10 @@ describe('Plex auth routes', () => {
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
expect(html).toContain('sessionStorage.setItem');
|
||||
// Token is now stored server-side, not in sessionStorage
|
||||
expect(html).toContain('token is stored server-side');
|
||||
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
|
||||
expect(authTokenCacheMock.set).toHaveBeenCalledWith('3', 'token');
|
||||
});
|
||||
|
||||
it('returns tokens for successful Plex auth', async () => {
|
||||
@@ -267,18 +278,20 @@ describe('Plex auth routes', () => {
|
||||
expect(html).toContain('#authData=');
|
||||
});
|
||||
|
||||
it('returns Plex home users when token is provided', async () => {
|
||||
it('returns Plex home users when pinId is provided', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.users).toHaveLength(1);
|
||||
expect(authTokenCacheMock.get).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('rejects Plex home users when token is missing', async () => {
|
||||
it('rejects Plex home users when pinId is missing', async () => {
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any);
|
||||
const payload = await response.json();
|
||||
@@ -287,18 +300,30 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when token not found in cache', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('SessionExpired');
|
||||
});
|
||||
|
||||
it('returns 500 when Plex home users fetch fails', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-pin-id': '123' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
|
||||
it('rejects profile switch without main account token', async () => {
|
||||
it('rejects profile switch without pinId', async () => {
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1' });
|
||||
@@ -310,10 +335,26 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
it('rejects profile switch when token not found in cache', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({});
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('SessionExpired');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -323,11 +364,12 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('returns 401 for invalid profile PIN', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN'));
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pin: '0000' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1', pin: '0000', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -337,6 +379,7 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('switches Plex profile using provided profile info', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
@@ -349,10 +392,11 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({
|
||||
userId: 'home-1',
|
||||
pin: '1234',
|
||||
pinId: '123',
|
||||
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
|
||||
});
|
||||
|
||||
@@ -361,9 +405,11 @@ describe('Plex auth routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(authTokenCacheMock.delete).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('switches Plex profile using getUserInfo fallback', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 'plex-3',
|
||||
@@ -382,8 +428,8 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
@@ -394,12 +440,13 @@ describe('Plex auth routes', () => {
|
||||
});
|
||||
|
||||
it('returns 500 when profile info lookup fails', async () => {
|
||||
authTokenCacheMock.get.mockReturnValue('cached-token');
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: null });
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' });
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-2', pin: '1234', pinId: '123' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('SelectProfilePage', () => {
|
||||
});
|
||||
|
||||
it('selects an unprotected profile and stores auth data', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
// Token is now stored server-side, only pinId needed in URL
|
||||
setMockSearchParams('pinId=123');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
@@ -71,6 +71,9 @@ describe('SelectProfilePage', () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/plex/home-users') {
|
||||
// Verify pinId header is sent instead of token
|
||||
const headers = (init as RequestInit)?.headers as Record<string, string>;
|
||||
expect(headers?.['X-Plex-Pin-Id']).toBe('123');
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
@@ -107,7 +110,7 @@ describe('SelectProfilePage', () => {
|
||||
});
|
||||
|
||||
it('prompts for a PIN and handles invalid submissions', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
// Token is now stored server-side, only pinId needed in URL
|
||||
setMockSearchParams('pinId=555');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
@@ -136,7 +139,8 @@ describe('SelectProfilePage', () => {
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
return makeJsonResponse({ message: 'Invalid PIN' }, false, 401);
|
||||
// Return InvalidPIN error type to trigger PIN error message
|
||||
return makeJsonResponse({ error: 'InvalidPIN', message: 'Invalid PIN' }, false, 401);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,19 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
// Mock credential migration service - passwords in tests are plaintext
|
||||
vi.mock('@/lib/services/credential-migration.service', () => ({
|
||||
isEncryptedFormat: () => false, // Test passwords are plaintext
|
||||
}));
|
||||
|
||||
// Mock encryption service
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => ({
|
||||
encrypt: (value: string) => `enc-${value}`,
|
||||
decrypt: (value: string) => value.replace('enc-', ''),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock qBittorrent and SABnzbd services - use vi.hoisted to ensure they're available at mock time
|
||||
const { qbtServiceMock, sabServiceMock } = vi.hoisted(() => ({
|
||||
qbtServiceMock: {
|
||||
|
||||
@@ -620,4 +620,120 @@ describe('chapter merger', () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Merged file not created/i);
|
||||
});
|
||||
|
||||
describe('metadata escaping', () => {
|
||||
it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: { duration: '60', bit_rate: '128000', tags: {} },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
await mergeChapters(chapters, {
|
||||
title: "It's Not Her",
|
||||
author: "O'Brien",
|
||||
narrator: "Jane's Voice",
|
||||
outputPath,
|
||||
});
|
||||
|
||||
// Get the args passed to spawn
|
||||
const spawnCall = spawnMock.mock.calls[0];
|
||||
const args = spawnCall[1] as string[];
|
||||
|
||||
// Find the title metadata arg (format after parsing: title="It's Not Her)
|
||||
const titleArg = args.find((arg: string) => arg.startsWith('title='));
|
||||
const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist='));
|
||||
const composerArg = args.find((arg: string) => arg.startsWith('composer='));
|
||||
|
||||
// Single quotes should appear as-is ('s), NOT escaped with backslash (\'s)
|
||||
// The args contain the value with opening quote: title="It's Not Her
|
||||
expect(titleArg).toContain("It's Not Her");
|
||||
expect(titleArg).not.toContain("\\'"); // No escaped single quotes
|
||||
expect(albumArtistArg).toContain("O'Brien");
|
||||
expect(albumArtistArg).not.toContain("\\'");
|
||||
expect(composerArg).toContain("Jane's Voice");
|
||||
expect(composerArg).not.toContain("\\'");
|
||||
|
||||
// Verify no backslash-escaped single quotes anywhere in args
|
||||
const allArgsJoined = args.join(' ');
|
||||
expect(allArgsJoined).not.toContain("\\'");
|
||||
});
|
||||
|
||||
it('properly escapes double quotes and special shell characters', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: { duration: '60', bit_rate: '128000', tags: {} },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
await mergeChapters(chapters, {
|
||||
title: 'Book "Quoted" $100',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
// Get the args passed to spawn
|
||||
const spawnCall = spawnMock.mock.calls[0];
|
||||
const args = spawnCall[1] as string[];
|
||||
|
||||
// Find the title arg - double quotes and $ should be escaped
|
||||
const titleArg = args.find((arg: string) => arg.startsWith('title='));
|
||||
|
||||
// Verify escaping is present for double quotes and dollar signs
|
||||
expect(titleArg).toContain('\\"Quoted\\"');
|
||||
expect(titleArg).toContain('\\$100');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Component: Cleanup Helpers Tests
|
||||
* Documentation: documentation/phase3/sabnzbd.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { removeEmptyParentDirectories } from '@/lib/utils/cleanup-helpers';
|
||||
|
||||
// Mock fs/promises
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
readdir: vi.fn(),
|
||||
rmdir: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
const loggerMock = vi.hoisted(() => ({
|
||||
RMABLogger: {
|
||||
create: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
forJob: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => loggerMock);
|
||||
|
||||
describe('removeEmptyParentDirectories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('removes a single empty parent directory', async () => {
|
||||
// Setup: /downloads/category/audiobook was deleted
|
||||
// /downloads/category is empty
|
||||
fsMock.readdir.mockResolvedValueOnce([]); // /downloads/category is empty
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toEqual(['/downloads/category']);
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
expect(fsMock.rmdir).toHaveBeenCalledWith('/downloads/category');
|
||||
});
|
||||
|
||||
it('removes multiple nested empty directories', async () => {
|
||||
// Setup: /downloads/cat/subcat/audiobook was deleted
|
||||
// Both /downloads/cat/subcat and /downloads/cat are empty
|
||||
fsMock.readdir
|
||||
.mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
|
||||
.mockResolvedValueOnce([]); // /downloads/cat is empty
|
||||
fsMock.rmdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/cat/subcat/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toHaveLength(2);
|
||||
expect(result.removedDirectories).toContain('/downloads/cat/subcat');
|
||||
expect(result.removedDirectories).toContain('/downloads/cat');
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
});
|
||||
|
||||
it('stops when encountering a non-empty directory', async () => {
|
||||
// Setup: /downloads/category/audiobook was deleted
|
||||
// /downloads/category has other files
|
||||
fsMock.readdir.mockResolvedValueOnce(['other-file.txt']);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toHaveLength(0);
|
||||
expect(result.stoppedReason).toBe('non_empty');
|
||||
expect(result.stoppedAt).toBe('/downloads/category');
|
||||
expect(fsMock.rmdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes first empty dir but stops at non-empty parent', async () => {
|
||||
// Setup: /downloads/cat/subcat/audiobook was deleted
|
||||
// /downloads/cat/subcat is empty, /downloads/cat has other stuff
|
||||
fsMock.readdir
|
||||
.mockResolvedValueOnce([]) // /downloads/cat/subcat is empty
|
||||
.mockResolvedValueOnce(['other-subcat']); // /downloads/cat has other subcat
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/cat/subcat/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toEqual(['/downloads/cat/subcat']);
|
||||
expect(result.stoppedReason).toBe('non_empty');
|
||||
expect(result.stoppedAt).toBe('/downloads/cat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary protection', () => {
|
||||
it('never deletes the boundary directory itself', async () => {
|
||||
// Setup: /downloads/audiobook was deleted (directly under boundary)
|
||||
// /downloads is empty
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toHaveLength(0);
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
// Should NOT try to remove /downloads
|
||||
expect(fsMock.rmdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never deletes above the boundary directory', async () => {
|
||||
// Setup: Deep nested structure with empty parents all the way up
|
||||
fsMock.readdir.mockResolvedValue([]); // All directories empty
|
||||
fsMock.rmdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/a/b/c/d/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should remove a/b/c/d, a/b/c, a/b, a - but NOT /downloads
|
||||
expect(result.removedDirectories).toHaveLength(4);
|
||||
expect(result.removedDirectories).not.toContain('/downloads');
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
});
|
||||
|
||||
it('handles boundary with trailing slash', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads/' } // Trailing slash
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toEqual(['/downloads/category']);
|
||||
});
|
||||
|
||||
it('handles path directly at boundary level', async () => {
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
// Parent of /downloads/audiobook is /downloads which is the boundary
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toHaveLength(0);
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles ENOENT gracefully (directory already deleted)', async () => {
|
||||
// First directory check succeeds (empty), rmdir fails with ENOENT
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should continue without error
|
||||
});
|
||||
|
||||
it('handles ENOENT when checking if directory exists', async () => {
|
||||
// Directory doesn't exist when we try to read it
|
||||
fsMock.readdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should handle gracefully, move to parent
|
||||
});
|
||||
|
||||
it('handles ENOTEMPTY race condition gracefully', async () => {
|
||||
// Directory was empty when checked, but became non-empty before removal
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOTEMPTY'), { code: 'ENOTEMPTY' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stoppedReason).toBe('non_empty');
|
||||
});
|
||||
|
||||
it('handles EACCES permission error gracefully', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Permission denied'), { code: 'EACCES' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
// Should still be considered partial success
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stoppedReason).toBe('error');
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('handles EPERM permission error gracefully', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stoppedReason).toBe('error');
|
||||
});
|
||||
|
||||
it('handles unexpected errors', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Unknown error'), { code: 'EUNKNOWN' })
|
||||
);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stoppedReason).toBe('error');
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path edge cases', () => {
|
||||
it('handles Windows-style backslash paths', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'C:\\downloads\\category\\audiobook',
|
||||
{ boundaryPath: 'C:\\downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should normalize paths and work correctly
|
||||
});
|
||||
|
||||
it('handles mixed slash paths', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category\\audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles paths with redundant slashes', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads//category///audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents /downloads2 from matching /downloads boundary', async () => {
|
||||
// If boundary is /downloads, path /downloads2/cat/audio should NOT match
|
||||
fsMock.readdir.mockResolvedValue([]); // All empty
|
||||
fsMock.rmdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads2/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
// Should reach root (no boundary match) or handle gracefully
|
||||
// The boundary check should NOT match /downloads2 when boundary is /downloads
|
||||
expect(result.success).toBe(true);
|
||||
// Should have removed /downloads2/category and /downloads2 (or hit root)
|
||||
});
|
||||
});
|
||||
|
||||
describe('with job context logging', () => {
|
||||
it('uses job-aware logger when context provided', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{
|
||||
boundaryPath: '/downloads',
|
||||
logContext: { jobId: 'job-123', context: 'TestCleanup' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(loggerMock.RMABLogger.forJob).toHaveBeenCalledWith('job-123', 'TestCleanup');
|
||||
});
|
||||
|
||||
it('uses default logger when no context provided', async () => {
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/category/audiobook',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Default logger is created at module load time, not per-call
|
||||
// Just verify the function works without job context
|
||||
expect(loggerMock.RMABLogger.forJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('realistic SABnzbd scenarios', () => {
|
||||
it('cleans up empty readmeabook category folder', async () => {
|
||||
// Real scenario: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
|
||||
// After organizing, My.Audiobook.Name is deleted
|
||||
// readmeabook folder should be cleaned up too
|
||||
fsMock.readdir.mockResolvedValueOnce([]); // /downloads/readmeabook is empty
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/readmeabook/My.Audiobook.Name',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toEqual(['/downloads/readmeabook']);
|
||||
expect(result.stoppedReason).toBe('boundary_reached');
|
||||
});
|
||||
|
||||
it('preserves category folder with other downloads', async () => {
|
||||
// Real scenario: Multiple downloads in readmeabook category
|
||||
// Only one is being cleaned up
|
||||
fsMock.readdir.mockResolvedValueOnce(['Other.Audiobook.Name']); // Other download exists
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/downloads/readmeabook/My.Audiobook.Name',
|
||||
{ boundaryPath: '/downloads' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toHaveLength(0);
|
||||
expect(result.stoppedReason).toBe('non_empty');
|
||||
});
|
||||
|
||||
it('handles path mapping scenario (mapped download_dir)', async () => {
|
||||
// Real scenario: download_dir is /media/usenet/complete
|
||||
// after path mapping from SABnzbd's perspective
|
||||
fsMock.readdir.mockResolvedValueOnce([]);
|
||||
fsMock.rmdir.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await removeEmptyParentDirectories(
|
||||
'/media/usenet/complete/readmeabook/My.Audiobook.Name',
|
||||
{ boundaryPath: '/media/usenet/complete' }
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.removedDirectories).toEqual(['/media/usenet/complete/readmeabook']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -113,4 +113,89 @@ describe('metadata tagger', () => {
|
||||
mockExecFailure('not installed');
|
||||
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe('metadata escaping', () => {
|
||||
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: "It's Not Her",
|
||||
author: "Author's Name",
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
// Single quotes should appear as-is, NOT escaped with backslash
|
||||
expect(command).toContain('-metadata title="It\'s Not Her"');
|
||||
expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
|
||||
expect(command).toContain('-metadata album_artist="Author\'s Name"');
|
||||
});
|
||||
|
||||
it('escapes double quotes in metadata values', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book "Title"',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\"Title\\""');
|
||||
});
|
||||
|
||||
it('escapes backticks to prevent command substitution', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book `test`',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\`test\\`"');
|
||||
});
|
||||
|
||||
it('escapes dollar signs to prevent variable expansion', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book $100',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book \\$100"');
|
||||
});
|
||||
|
||||
it('escapes backslashes before other characters', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Path\\to\\book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
|
||||
});
|
||||
|
||||
it('handles complex titles with multiple special characters', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: "Don't Say \"Hello\" for $5",
|
||||
author: "O'Brien",
|
||||
});
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
// Single quotes literal, double quotes escaped, dollar escaped
|
||||
expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
|
||||
expect(command).toContain('-metadata album_artist="O\'Brien"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user