Merge branch 'main' into feature/hardover-shelves

This commit is contained in:
kikootwo
2026-03-04 23:16:08 -05:00
committed by GitHub
45 changed files with 3338 additions and 223 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* Component: API Token Constants
* Documentation: documentation/backend/services/api-tokens.md
*
* Centralized API token constants used across authentication middleware and token routes.
*/
/** Prefix prepended to all generated API tokens for identification */
export const API_TOKEN_PREFIX = 'rmab_';
/** Number of random bytes used to generate the token's random portion */
export const TOKEN_RANDOM_BYTES = 32;
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
export const TOKEN_PREFIX_LENGTH = 12;
/** Maximum number of active (non-expired) API tokens a single user may hold */
export const MAX_TOKENS_PER_USER = 25;
// ---------------------------------------------------------------------------
// Endpoint allowlist — restricts which routes API tokens may access
// ---------------------------------------------------------------------------
/** Shape of an allowed endpoint entry */
export interface AllowedEndpoint {
method: string;
path: string;
}
/** Extended metadata used by the interactive API docs page */
export interface EndpointDoc {
method: string;
path: string;
title: string;
description: string;
requiresAdmin: boolean;
}
/**
* Endpoints that API tokens are permitted to call.
* JWT-authenticated sessions are NOT restricted by this list.
*/
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
{ method: 'GET', path: '/api/auth/me' },
{ method: 'GET', path: '/api/requests' },
{ method: 'GET', path: '/api/admin/metrics' },
{ method: 'GET', path: '/api/admin/downloads/active' },
{ method: 'GET', path: '/api/admin/requests/recent' },
] as const;
/**
* Full documentation metadata for each allowed endpoint.
* Consumed by the /api-docs interactive page.
*/
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
{
method: 'GET',
path: '/api/auth/me',
title: 'Get current user',
description:
'Returns the authenticated user\'s profile information including username, role, and account details.',
requiresAdmin: false,
},
{
method: 'GET',
path: '/api/requests',
title: 'List requests',
description:
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
requiresAdmin: false,
},
{
method: 'GET',
path: '/api/admin/metrics',
title: 'System metrics',
description:
'Returns system health metrics including request counts, download statistics, and library size.',
requiresAdmin: true,
},
{
method: 'GET',
path: '/api/admin/downloads/active',
title: 'Active downloads',
description:
'Returns currently active downloads including progress, speed, and ETA.',
requiresAdmin: true,
},
{
method: 'GET',
path: '/api/admin/requests/recent',
title: 'Recent requests',
description:
'Returns the most recent audiobook requests across all users.',
requiresAdmin: true,
},
] as const;
/**
* Check whether a given method + path is on the API token allowlist.
* Method comparison is case-insensitive.
*/
export function isEndpointAllowed(method: string, path: string): boolean {
const upperMethod = method.toUpperCase();
return API_TOKEN_ALLOWED_ENDPOINTS.some(
(ep) => ep.method === upperMethod && ep.path === path
);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Component: Shared API Token Management Hook
* Documentation: documentation/backend/services/api-tokens.md
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { ApiToken } from '@/lib/types/api-tokens';
/** Typed request body for creating an API token */
export interface CreateTokenBody {
name: string;
expiresAt: string | null;
userId?: string;
role?: string;
}
interface UseApiTokensConfig {
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
basePath: string;
}
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
tokens: T[];
loading: boolean;
creating: boolean;
error: string | null;
newTokenName: string;
setNewTokenName: (name: string) => void;
newTokenExpiry: string;
setNewTokenExpiry: (expiry: string) => void;
showCreateForm: boolean;
setShowCreateForm: (show: boolean) => void;
createdToken: string | null;
copied: boolean;
deletingId: string | null;
confirmRevokeId: string | null;
setConfirmRevokeId: (id: string | null) => void;
fetchTokens: () => Promise<void>;
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<boolean>;
handleDeleteConfirmed: () => Promise<void>;
handleCopy: () => Promise<void>;
dismissCreatedToken: () => void;
resetForm: () => void;
formatDate: (dateStr: string | null) => string;
}
/**
* Shared hook for API token CRUD operations.
* Used by both the admin ApiTab and the user ApiTokensSection.
*/
export function useApiTokens<T extends ApiToken = ApiToken>(
config: UseApiTokensConfig
): UseApiTokensReturn<T> {
const [tokens, setTokens] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
const [showCreateForm, setShowCreateForm] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const fetchTokens = useCallback(async () => {
try {
const response = await fetchWithAuth(config.basePath);
if (!response.ok) {
let message = 'Failed to load API tokens';
try {
const data = await response.json();
message = data.error || message;
} catch {
// Keep default message when response body is not JSON
}
setError(message);
return;
}
const data = await response.json();
setTokens(data.tokens);
setError(null);
} catch {
setError('Failed to load API tokens');
} finally {
setLoading(false);
}
}, [config.basePath]);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const computeExpiresAt = (): string | null => {
if (newTokenExpiry === 'never') return null;
const date = new Date();
switch (newTokenExpiry) {
case '30d': date.setDate(date.getDate() + 30); break;
case '90d': date.setDate(date.getDate() + 90); break;
case '1y': date.setFullYear(date.getFullYear() + 1); break;
}
return date.toISOString();
};
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
if (!newTokenName.trim()) {
setError('Token name is required');
return false;
}
setCreating(true);
setError(null);
try {
const body: CreateTokenBody = {
name: newTokenName.trim(),
expiresAt: computeExpiresAt(),
...extraBody,
};
const response = await fetchWithAuth(config.basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (response.ok) {
const data = await response.json();
setCreatedToken(data.fullToken);
setNewTokenName('');
setNewTokenExpiry('never');
setShowCreateForm(false);
await fetchTokens();
return true;
} else {
const data = await response.json();
setError(data.error || 'Failed to create token');
return false;
}
} catch {
setError('Failed to create token');
return false;
} finally {
setCreating(false);
}
};
const handleDeleteConfirmed = async () => {
const id = confirmRevokeId;
if (!id) return;
setConfirmRevokeId(null);
setDeletingId(id);
setError(null);
try {
const response = await fetchWithAuth(`${config.basePath}/${id}`, {
method: 'DELETE',
});
if (response.ok) {
setTokens(tokens.filter((t) => t.id !== id));
} else {
setError('Failed to revoke token');
}
} catch {
setError('Failed to revoke token');
} finally {
setDeletingId(null);
}
};
const handleCopy = async () => {
if (createdToken) {
try {
await navigator.clipboard.writeText(createdToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError('Failed to copy to clipboard. Please select and copy the token manually.');
}
}
};
const dismissCreatedToken = () => setCreatedToken(null);
const resetForm = () => {
setShowCreateForm(false);
setNewTokenName('');
setNewTokenExpiry('never');
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return {
tokens,
loading,
creating,
error,
newTokenName,
setNewTokenName,
newTokenExpiry,
setNewTokenExpiry,
showCreateForm,
setShowCreateForm,
createdToken,
copied,
deletingId,
confirmRevokeId,
setConfirmRevokeId,
fetchTokens,
handleCreate,
handleDeleteConfirmed,
handleCopy,
dismissCreatedToken,
resetForm,
formatDate,
};
}
+107 -3
View File
@@ -4,9 +4,11 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
const logger = RMABLogger.create('Auth');
@@ -32,9 +34,70 @@ function extractToken(request: NextRequest): string | null {
return parts[1];
}
/**
* Authenticate via static API token (rmab_ prefix).
* Returns a synthetic TokenPayload if valid, null otherwise.
* Updates lastUsedAt asynchronously.
*/
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = await prisma.apiToken.findUnique({
where: { tokenHash },
include: {
tokenUser: {
select: {
id: true,
plexId: true,
plexUsername: true,
role: true,
deletedAt: true,
},
},
},
});
if (!apiToken) return null;
// Check expiration
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
return null;
}
// Reject tokens for soft-deleted users
const user = apiToken.tokenUser;
if (!user || user.deletedAt) {
logger.warn('API token used by deleted or missing user', {
tokenPrefix: apiToken.tokenPrefix,
userId: user?.id,
});
return null;
}
// Update lastUsedAt (fire-and-forget)
prisma.apiToken.update({
where: { id: apiToken.id },
data: { lastUsedAt: new Date() },
}).catch((err) => {
logger.debug('Failed to update API token lastUsedAt', {
error: err instanceof Error ? err.message : String(err),
tokenId: apiToken.id,
});
});
// Use the token's target user (userId), not the creator (createdById)
return {
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: apiToken.role,
};
}
/**
* Middleware: Require authentication
* Verifies JWT token and adds user to request
* Verifies JWT token or static API token and adds user to request
*/
export async function requireAuth(
request: NextRequest,
@@ -53,6 +116,43 @@ export async function requireAuth(
);
}
// Check if this is a static API token
if (token.startsWith(API_TOKEN_PREFIX)) {
const apiUser = await authenticateApiToken(token);
if (!apiUser) {
logger.error('API token authentication failed');
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Invalid or expired API token',
},
{ status: 401 }
);
}
// Enforce endpoint allowlist for API token auth
const pathname = request.nextUrl.pathname;
const method = request.method;
if (!isEndpointAllowed(method, pathname)) {
logger.warn('API token used on restricted endpoint', {
method,
path: pathname,
});
return NextResponse.json(
{
error: 'Forbidden',
message: 'This endpoint is not available via API token authentication',
},
{ status: 403 }
);
}
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
return handler(authenticatedRequest);
}
// Fall back to JWT verification
const payload = verifyAccessToken(token);
if (!payload) {
@@ -69,9 +169,13 @@ export async function requireAuth(
// Verify user still exists in database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
deletedAt: true,
},
});
if (!user) {
if (!user || user.deletedAt) {
logger.error('User not found in database', { userId: payload.sub });
return NextResponse.json(
{
@@ -86,7 +190,7 @@ export async function requireAuth(
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = {
...payload,
id: user.id,
id: payload.sub,
};
return handler(authenticatedRequest);
+109 -3
View File
@@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
import { getAudibleService } from '../integrations/audible.service';
/**
* Process organize files job
@@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
// Enrich missing series data from Audnexus (safety net for records created without series)
let series = audiobook.series || undefined;
let seriesPart = audiobook.seriesPart || undefined;
if (audiobook.audibleAsin && !series) {
try {
logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`);
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (audnexusData.series) {
series = audnexusData.series;
updates.series = series;
logger.info(`Got series "${series}" from Audnexus`);
}
if (audnexusData.seriesPart) {
seriesPart = audnexusData.seriesPart;
updates.seriesPart = seriesPart;
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
}
if (audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
// Also backfill year/narrator if still missing
if (!year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
updates.year = year;
logger.info(`Got year ${year} from Audnexus`);
}
}
if (!narrator && audnexusData.narrator) {
narrator = audnexusData.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from Audnexus`);
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated audiobook record with Audnexus metadata`);
}
}
} catch (error) {
// Non-fatal: missing series won't block organization, just degrades path quality
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
// Get file organizer (reads media_dir from database config)
const organizer = await getFileOrganizer();
@@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
coverArtUrl: audiobook.coverArtUrl || undefined,
asin: audiobook.audibleAsin || undefined,
year,
series: audiobook.series || undefined,
seriesPart: audiobook.seriesPart || undefined,
series,
seriesPart,
},
template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
@@ -545,6 +601,56 @@ async function processEbookOrganization(
}
}
// Enrich missing series data from Audnexus (safety net for records created without series)
if (book.audibleAsin && !series) {
try {
logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`);
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (audnexusData.series) {
series = audnexusData.series;
updates.series = series;
logger.info(`Got series "${series}" from Audnexus`);
}
if (audnexusData.seriesPart) {
seriesPart = audnexusData.seriesPart;
updates.seriesPart = seriesPart;
logger.info(`Got seriesPart "${seriesPart}" from Audnexus`);
}
if (audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
if (!year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
updates.year = year;
logger.info(`Got year ${year} from Audnexus`);
}
}
if (!narrator && audnexusData.narrator) {
narrator = audnexusData.narrator;
updates.narrator = narrator;
logger.info(`Got narrator "${narrator}" from Audnexus`);
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobookId },
data: updates,
});
logger.info(`Updated book record with Audnexus metadata`);
}
}
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
// Check if this is an indexer download (needs to keep source for seeding)
+8 -5
View File
@@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username and password required' };
}
const normalizedUsername = username.trim().toLowerCase();
// Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Exclude soft-deleted users
},
@@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider {
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
const normalizedUsername = username?.trim().toLowerCase();
// Validate
if (!username || username.length < 3) {
if (!normalizedUsername || normalizedUsername.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
@@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider {
// Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
plexUsername: normalizedUsername,
authProvider: 'local',
deletedAt: null, // Allow reuse of usernames from deleted accounts
},
@@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider {
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
plexId: `local-${normalizedUsername}`,
plexUsername: normalizedUsername,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
+23
View File
@@ -0,0 +1,23 @@
/**
* Component: API Token Type Definitions
* Documentation: documentation/backend/services/api-tokens.md
*/
/** Base API token as returned by user-facing endpoints */
export interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
role: string;
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
}
/** Extended API token with cross-user fields, returned by admin endpoints */
export interface AdminApiToken extends ApiToken {
createdBy: string;
createdById: string;
tokenUser: string;
tokenUserId: string;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Component: API Token Generation Utility
* Documentation: documentation/backend/services/api-tokens.md
*/
import crypto from 'crypto';
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
interface GeneratedToken {
/** The full token string to return to the user (shown only once) */
fullToken: string;
/** SHA-256 hash of the full token (stored in database) */
tokenHash: string;
/** Display prefix for identification (first 12 chars) */
tokenPrefix: string;
}
/**
* Generate a new API token with its hash and display prefix.
* The full token is: API_TOKEN_PREFIX + random hex string.
* Only the hash is stored; the full token is returned once at creation.
*/
export function generateApiToken(): GeneratedToken {
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
const tokenPrefix = fullToken.substring(0, TOKEN_PREFIX_LENGTH);
return { fullToken, tokenHash, tokenPrefix };
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Component: API Token Rate Limiting
* Documentation: documentation/backend/services/api-tokens.md
*
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
* to prevent unbounded memory growth.
*/
type Bucket = {
count: number;
resetAt: number;
};
type RateLimitResult = {
allowed: boolean;
retryAfterSeconds: number;
};
const buckets = new Map<string, Bucket>();
/** Number of checkRateLimit calls since the last full sweep */
let checkCount = 0;
/** How often (in calls) to perform a full sweep of expired buckets */
const SWEEP_INTERVAL = 100;
/**
* Sweep the entire bucket map and delete all expired entries.
* Called automatically every SWEEP_INTERVAL checks.
*/
function sweepExpiredBuckets(): void {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now >= bucket.resetAt) {
buckets.delete(key);
}
}
}
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
const now = Date.now();
// Periodic full sweep every SWEEP_INTERVAL calls
checkCount += 1;
if (checkCount >= SWEEP_INTERVAL) {
checkCount = 0;
sweepExpiredBuckets();
}
const current = buckets.get(key);
// Lazy eviction: if the bucket is expired, delete it and start fresh
if (!current || now >= current.resetAt) {
if (current) {
buckets.delete(key);
}
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
}
if (current.count >= maxRequests) {
return {
allowed: false,
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
};
}
current.count += 1;
return {
allowed: true,
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
};
}
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
}
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
}
/** Reset all buckets and the sweep counter. For testing only. */
export function _resetBuckets(): void {
buckets.clear();
checkCount = 0;
}
/** Get the current number of tracked buckets. For testing only. */
export function _getBucketCount(): number {
return buckets.size;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Component: Client-side URL Utilities
* Documentation: documentation/backend/services/api-tokens.md
*/
/**
* Get the current instance origin URL.
* Returns window.location.origin on the client, or a placeholder on the server.
*/
export function getInstanceUrl(): string {
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
}