新增电子书架
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { hasFeaturePermission } from '@/lib/permissions';
|
||||
|
||||
export async function getAuthorizedBooksUsername(request: NextRequest): Promise<string | NextResponse> {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
const user = await db.getUserInfoV2(authInfo.username);
|
||||
if (!user || user.banned) {
|
||||
return NextResponse.json({ error: '用户不存在或已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const allowed = await hasFeaturePermission(authInfo.username, 'books');
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: '无权限访问电子书功能' }, { status: 403 });
|
||||
}
|
||||
|
||||
return authInfo.username;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
const href = searchParams.get('href')?.trim() || undefined;
|
||||
if (!sourceId) {
|
||||
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||
}
|
||||
const result = await opdsClient.getCatalog(sourceId, href);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
const href = searchParams.get('href')?.trim() || '';
|
||||
|
||||
if (!sourceId) {
|
||||
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const acquisitionLinksRaw = searchParams.get('acquisitionLinks');
|
||||
let acquisitionLinks: BookAcquisitionLink[] | undefined;
|
||||
if (acquisitionLinksRaw) {
|
||||
try {
|
||||
acquisitionLinks = JSON.parse(acquisitionLinksRaw) as BookAcquisitionLink[];
|
||||
} catch {
|
||||
acquisitionLinks = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const detail = await opdsClient.getBookDetail(sourceId, href, {
|
||||
id: searchParams.get('bookId') || undefined,
|
||||
title: searchParams.get('title') || undefined,
|
||||
author: searchParams.get('author') || undefined,
|
||||
cover: searchParams.get('cover') || undefined,
|
||||
summary: searchParams.get('summary') || undefined,
|
||||
detailHref: href || undefined,
|
||||
acquisitionLinks,
|
||||
});
|
||||
return NextResponse.json(detail);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
const href = searchParams.get('href')?.trim();
|
||||
if (!sourceId || !href) {
|
||||
return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = await opdsClient.getSourceById(sourceId);
|
||||
const headers = new Headers();
|
||||
if (source.authMode === 'basic' && source.username) {
|
||||
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
|
||||
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
|
||||
headers.set(source.headerName, source.headerValue);
|
||||
}
|
||||
const range = request.headers.get('range');
|
||||
if (range) headers.set('Range', range);
|
||||
|
||||
const response = await fetch(href, {
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
|
||||
}
|
||||
|
||||
const outHeaders = new Headers();
|
||||
const contentType = response.headers.get('content-type');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
const contentRange = response.headers.get('content-range');
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
if (contentType) outHeaders.set('Content-Type', contentType);
|
||||
if (contentLength) outHeaders.set('Content-Length', contentLength);
|
||||
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
|
||||
if (contentRange) outHeaders.set('Content-Range', contentRange);
|
||||
if (disposition) outHeaders.set('Content-Disposition', disposition);
|
||||
outHeaders.set('Cache-Control', 'private, max-age=300');
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers: outHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { BookReadRecord } from '@/lib/book.types';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
const record = await db.getBookReadRecord(username, sourceId, bookId);
|
||||
return NextResponse.json(record, { status: 200 });
|
||||
}
|
||||
|
||||
const records = await db.getAllBookReadRecords(username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { key, record }: { key: string; record: BookReadRecord } = await request.json();
|
||||
if (!key || !record?.locator?.value) {
|
||||
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
|
||||
}
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
|
||||
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
|
||||
const existingRecord = await db.getBookReadRecord(username, sourceId, bookId);
|
||||
const normalizedRecord: BookReadRecord = {
|
||||
...record,
|
||||
sourceId: record.sourceId || sourceId,
|
||||
bookId: record.bookId || bookId,
|
||||
sourceName: record.sourceName || shelfItem?.sourceName || existingRecord?.sourceName || '',
|
||||
detailHref: record.detailHref || shelfItem?.detailHref || existingRecord?.detailHref,
|
||||
acquisitionHref: record.acquisitionHref || shelfItem?.acquisitionHref || existingRecord?.acquisitionHref,
|
||||
author: record.author || shelfItem?.author || existingRecord?.author,
|
||||
cover: record.cover || shelfItem?.cover || existingRecord?.cover,
|
||||
saveTime: record.saveTime ?? Date.now(),
|
||||
};
|
||||
await db.saveBookReadRecord(username, sourceId, bookId, normalizedRecord);
|
||||
|
||||
if (shelfItem) {
|
||||
await db.saveBookShelf(username, sourceId, bookId, {
|
||||
...shelfItem,
|
||||
format: normalizedRecord.format,
|
||||
progressPercent: normalizedRecord.progressPercent,
|
||||
lastReadTime: normalizedRecord.saveTime,
|
||||
lastLocatorType: normalizedRecord.locator.type,
|
||||
lastLocatorValue: normalizedRecord.locator.value,
|
||||
lastChapterTitle: normalizedRecord.chapterTitle || normalizedRecord.locator.chapterTitle,
|
||||
});
|
||||
}
|
||||
|
||||
if ((db as any).storage.cleanupOldBookReadRecords) {
|
||||
(db as any).storage.cleanupOldBookReadRecords(username).catch((err: Error) => {
|
||||
console.error('异步清理电子书阅读历史失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
await db.deleteBookReadRecord(username, sourceId, bookId);
|
||||
} else {
|
||||
const all = await db.getAllBookReadRecords(username);
|
||||
await Promise.all(Object.keys(all).map(async (itemKey) => {
|
||||
const [sourceId, bookId] = itemKey.split('+');
|
||||
if (sourceId && bookId) await db.deleteBookReadRecord(username, sourceId, bookId);
|
||||
}));
|
||||
}
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
const href = searchParams.get('href')?.trim();
|
||||
const acquisitionHref = searchParams.get('acquisitionHref')?.trim();
|
||||
const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null;
|
||||
const bookId = searchParams.get('bookId')?.trim();
|
||||
|
||||
if (!sourceId) {
|
||||
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingRecord = bookId ? await db.getBookReadRecord(username, sourceId, bookId) : null;
|
||||
const shelfItem = bookId ? await db.getBookShelf(username, sourceId, bookId) : null;
|
||||
const resolvedHref = href || existingRecord?.detailHref || shelfItem?.detailHref || '';
|
||||
const resolvedAcquisitionHref = acquisitionHref || existingRecord?.acquisitionHref || shelfItem?.acquisitionHref || '';
|
||||
const resolvedFormat = format || existingRecord?.format || shelfItem?.format || 'epub';
|
||||
|
||||
if (!resolvedHref && !resolvedAcquisitionHref) {
|
||||
return NextResponse.json({ error: '缺少 href / acquisitionHref,且历史记录中也没有可恢复的下载链接' }, { status: 400 });
|
||||
}
|
||||
|
||||
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
|
||||
? [{
|
||||
rel: 'http://opds-spec.org/acquisition',
|
||||
type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
|
||||
href: resolvedAcquisitionHref,
|
||||
}]
|
||||
: [];
|
||||
|
||||
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
|
||||
id: bookId || resolvedAcquisitionHref || undefined,
|
||||
title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined,
|
||||
author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined,
|
||||
cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined,
|
||||
summary: searchParams.get('summary') || undefined,
|
||||
detailHref: resolvedHref || undefined,
|
||||
acquisitionLinks: fallbackAcquisitionLinks,
|
||||
});
|
||||
const preferred = resolvedHref
|
||||
? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref)
|
||||
: {
|
||||
format: resolvedFormat === 'pdf' ? 'pdf' : 'epub',
|
||||
href: resolvedAcquisitionHref || '',
|
||||
};
|
||||
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
|
||||
|
||||
return NextResponse.json({
|
||||
book: detail,
|
||||
format: preferred.format,
|
||||
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`,
|
||||
acquisitionHref: preferred.href,
|
||||
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
|
||||
coverUrl: detail.cover,
|
||||
lastRecord,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get('q')?.trim();
|
||||
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [], failedSources: [] });
|
||||
}
|
||||
const result = await opdsClient.searchBooks(q, sourceId);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { BookShelfItem } from '@/lib/book.types';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
const item = await db.getBookShelf(username, sourceId, bookId);
|
||||
return NextResponse.json(item, { status: 200 });
|
||||
}
|
||||
|
||||
const records = await db.getAllBookShelf(username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { key, item }: { key: string; item: BookShelfItem } = await request.json();
|
||||
if (!key || !item?.title) return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
|
||||
await db.saveBookShelf(username, sourceId, bookId, {
|
||||
...item,
|
||||
sourceId: item.sourceId || sourceId,
|
||||
bookId: item.bookId || bookId,
|
||||
saveTime: item.saveTime ?? Date.now(),
|
||||
});
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const key = new URL(request.url).searchParams.get('key');
|
||||
if (key) {
|
||||
const [sourceId, bookId] = key.split('+');
|
||||
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
await db.deleteBookShelf(username, sourceId, bookId);
|
||||
} else {
|
||||
const all = await db.getAllBookShelf(username);
|
||||
await Promise.all(Object.keys(all).map(async (itemKey) => {
|
||||
const [sourceId, bookId] = itemKey.split('+');
|
||||
if (sourceId && bookId) await db.deleteBookShelf(username, sourceId, bookId);
|
||||
}));
|
||||
}
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { opdsClient } from '@/lib/opds.client';
|
||||
|
||||
import { getAuthorizedBooksUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedBooksUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const sources = await opdsClient.getSources();
|
||||
return NextResponse.json({ sources });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user