mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 05:10:11 +00:00
Address PR review: dedicated download secret, shared constants, strip filePath, streaming zip
- jwt.ts: Use JWT_DOWNLOAD_SECRET instead of JWT_SECRET for download tokens - audio-formats.ts: Add EBOOK_EXTENSIONS export alongside AUDIO_EXTENSIONS - request-statuses.ts: New shared COMPLETED_STATUSES constant used across requests API, download route, and RequestCard - requests/route.ts: Import COMPLETED_STATUSES; strip filePath from audiobook in API response - download/route.ts: Import format/status constants; add archiver dependency and replace adm-zip with streaming archiver for multi-file zips - RequestCard.tsx: Use shared COMPLETED_STATUSES constant
This commit is contained in:
@@ -54,3 +54,4 @@ next-env.d.ts
|
|||||||
/pgdata
|
/pgdata
|
||||||
/test-media
|
/test-media
|
||||||
/test-data
|
/test-data
|
||||||
|
dockerfile.patch
|
||||||
|
|||||||
Generated
+820
-31
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -18,7 +18,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bull": "^4.12.0",
|
"bull": "^4.12.0",
|
||||||
@@ -43,9 +45,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/adm-zip": "^0.5.6",
|
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/adm-zip": "^0.5.6",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/bull": "^4.10.0",
|
"@types/bull": "^4.10.0",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { verifyDownloadToken } from '@/lib/utils/jwt';
|
import { verifyDownloadToken } from '@/lib/utils/jwt';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { AUDIO_EXTENSIONS, EBOOK_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import AdmZip from 'adm-zip';
|
import archiver from 'archiver';
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Download');
|
const logger = RMABLogger.create('API.Download');
|
||||||
|
|
||||||
const AUDIOBOOK_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax', '.flac', '.ogg'];
|
|
||||||
const EBOOK_EXTENSIONS = ['.epub', '.pdf', '.mobi', '.azw3', '.fb2', '.cbz', '.cbr'];
|
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
return name
|
return name
|
||||||
.replace(/[<>:"/\\|?*]/g, '')
|
.replace(/[<>:"/\\|?*]/g, '')
|
||||||
@@ -58,8 +58,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'NotFound', message: 'Request not found' }, { status: 404 });
|
return NextResponse.json({ error: 'NotFound', message: 'Request not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPLETED_STATUSES = ['available', 'downloaded'];
|
if (!COMPLETED_STATUSES.includes(requestRecord.status as typeof COMPLETED_STATUSES[number])) {
|
||||||
if (!COMPLETED_STATUSES.includes(requestRecord.status)) {
|
|
||||||
return NextResponse.json({ error: 'BadRequest', message: 'Request is not yet completed' }, { status: 400 });
|
return NextResponse.json({ error: 'BadRequest', message: 'Request is not yet completed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestType = requestRecord.type || 'audiobook';
|
const requestType = requestRecord.type || 'audiobook';
|
||||||
const allowedExtensions = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIOBOOK_EXTENSIONS;
|
const allowedExtensions: readonly string[] = requestType === 'ebook' ? EBOOK_EXTENSIONS : AUDIO_EXTENSIONS;
|
||||||
|
|
||||||
const allEntries = fs.readdirSync(resolvedDir);
|
const allEntries = fs.readdirSync(resolvedDir);
|
||||||
const matchingFiles = allEntries
|
const matchingFiles = allEntries
|
||||||
@@ -117,16 +116,26 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple files — zip them in memory
|
// Multiple files — stream zip via archiver (avoids loading all files into memory)
|
||||||
const zip = new AdmZip();
|
const passThrough = new PassThrough();
|
||||||
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
|
archive.pipe(passThrough);
|
||||||
for (const filePath of matchingFiles) {
|
for (const filePath of matchingFiles) {
|
||||||
zip.addLocalFile(filePath);
|
archive.file(filePath, { name: path.basename(filePath) });
|
||||||
}
|
}
|
||||||
const zipBuffer = zip.toBuffer();
|
archive.finalize();
|
||||||
|
|
||||||
const zipReadable = new ReadableStream({
|
const zipReadable = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
controller.enqueue(new Uint8Array(zipBuffer));
|
passThrough.on('data', chunk => controller.enqueue(new Uint8Array(chunk)));
|
||||||
controller.close();
|
passThrough.on('end', () => controller.close());
|
||||||
|
passThrough.on('error', err => {
|
||||||
|
logger.error('Zip stream error', { error: err.message });
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
archive.abort();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +143,6 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
|
'Content-Disposition': `attachment; filename="${sanitizedTitle}.zip"`,
|
||||||
'Content-Length': String(zipBuffer.byteLength),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
|||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||||
import { generateDownloadToken } from '@/lib/utils/jwt';
|
import { generateDownloadToken } from '@/lib/utils/jwt';
|
||||||
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Requests');
|
const logger = RMABLogger.create('API.Requests');
|
||||||
|
|
||||||
@@ -147,13 +148,14 @@ export async function GET(request: NextRequest) {
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const COMPLETED_STATUSES = ['available', 'downloaded'];
|
|
||||||
const enriched = requests.map(r => {
|
const enriched = requests.map(r => {
|
||||||
const isCompleted = COMPLETED_STATUSES.includes(r.status);
|
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
|
||||||
const hasFile = isCompleted && r.audiobook?.filePath;
|
const hasFile = isCompleted && r.audiobook?.filePath;
|
||||||
if (!hasFile) return r;
|
const token = hasFile ? generateDownloadToken(req.user!.id, r.id) : null;
|
||||||
const token = generateDownloadToken(req.user!.id, r.id);
|
const downloadUrl = token ? `/api/requests/${r.id}/download?token=${token}` : undefined;
|
||||||
return { ...r, downloadUrl: `/api/requests/${r.id}/download?token=${token}` };
|
// Strip server-side absolute path from client response
|
||||||
|
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
|
||||||
|
return { ...r, audiobook, ...(downloadUrl ? { downloadUrl } : {}) };
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
|
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -52,7 +53,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const requestType = request.type || 'audiobook';
|
const requestType = request.type || 'audiobook';
|
||||||
const isEbook = requestType === 'ebook';
|
const isEbook = requestType === 'ebook';
|
||||||
|
|
||||||
const isCompleted = ['available', 'downloaded'].includes(request.status);
|
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
@@ -322,7 +323,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
isOpen={showDetailsModal}
|
isOpen={showDetailsModal}
|
||||||
onClose={() => setShowDetailsModal(false)}
|
onClose={() => setShowDetailsModal(false)}
|
||||||
requestStatus={request.status}
|
requestStatus={request.status}
|
||||||
isAvailable={['available', 'downloaded'].includes(request.status)}
|
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
|
||||||
hideRequestActions
|
hideRequestActions
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,3 +67,16 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
|
|||||||
* 'OTHER' is used when no recognized format is detected in the title.
|
* 'OTHER' is used when no recognized format is detected in the title.
|
||||||
*/
|
*/
|
||||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All supported ebook file extensions for ebook detection and file serving.
|
||||||
|
*/
|
||||||
|
export const EBOOK_EXTENSIONS = [
|
||||||
|
'.epub',
|
||||||
|
'.pdf',
|
||||||
|
'.mobi',
|
||||||
|
'.azw3',
|
||||||
|
'.fb2',
|
||||||
|
'.cbz',
|
||||||
|
'.cbr',
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Component: Request Status Constants
|
||||||
|
* Documentation: documentation/backend/database.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
||||||
|
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
|
||||||
@@ -10,6 +10,7 @@ const logger = RMABLogger.create('JWT');
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
|
||||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
|
||||||
|
const JWT_DOWNLOAD_SECRET = process.env.JWT_DOWNLOAD_SECRET || JWT_SECRET + '-download';
|
||||||
|
|
||||||
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
|
const ACCESS_TOKEN_EXPIRY = '1h'; // 1 hour
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
|
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
|
||||||
@@ -91,7 +92,7 @@ export interface DownloadTokenPayload {
|
|||||||
*/
|
*/
|
||||||
export function generateDownloadToken(userId: string, requestId: string): string {
|
export function generateDownloadToken(userId: string, requestId: string): string {
|
||||||
const payload: DownloadTokenPayload = { sub: userId, requestId, type: 'download' };
|
const payload: DownloadTokenPayload = { sub: userId, requestId, type: 'download' };
|
||||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: DOWNLOAD_TOKEN_EXPIRY });
|
return jwt.sign(payload, JWT_DOWNLOAD_SECRET, { expiresIn: DOWNLOAD_TOKEN_EXPIRY });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +100,7 @@ export function generateDownloadToken(userId: string, requestId: string): string
|
|||||||
*/
|
*/
|
||||||
export function verifyDownloadToken(token: string): DownloadTokenPayload | null {
|
export function verifyDownloadToken(token: string): DownloadTokenPayload | null {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as DownloadTokenPayload;
|
const decoded = jwt.verify(token, JWT_DOWNLOAD_SECRET) as DownloadTokenPayload;
|
||||||
if (decoded.type !== 'download') return null;
|
if (decoded.type !== 'download') return null;
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user