From 32595c87a52c1ce6a5d1212fedf13fe0a6acfd08 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Fri, 30 Oct 2026 02:03:45 +0000 Subject: [PATCH 1/3] feat: file management is admin-only; members upload books behind a setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browse move/rename/mkdir/delete/metadata now require an administrator (403 ADMIN_REQUIRED for members). - Members may upload, but only add books: unsupported formats are skipped, zip/cbz are stored as comic archives rather than unpacked, and overwrite is refused. The new business setting library.memberUpload (default on) turns member uploads off (403 UPLOAD_DISABLED); GET /library/upload reports `allowed`. - The library page shows 上传书籍 to members when uploads are allowed. - Shelf tests read user_books for the owner only, now that a second user exists. Co-Authored-By: Claude Opus 5.5 --- server/src/http/routes/library.ts | 40 +++++++++---- server/src/services/browse.ts | 8 +-- server/src/services/business-settings.ts | 4 ++ server/src/services/uploads.ts | 36 ++++++++++-- server/test/browse.test.ts | 33 +++++++++-- server/test/uploads.test.ts | 56 +++++++++++++++++-- web/src/api/client.ts | 12 ++-- .../features/library/pages/LibraryPage.tsx | 11 +++- web/src/styles/reader.css | 2 +- 9 files changed, 165 insertions(+), 37 deletions(-) diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 4a42dcf..5297c49 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import type { AppContext } from '../context.ts'; import { authenticate, currentUser, requireAdmin } from '../auth.ts'; -import { badRequest, notFound } from '../../lib/errors.ts'; +import { badRequest, forbidden, notFound } from '../../lib/errors.ts'; import { parseConflictPolicy, type ConflictPolicy, type StagedUpload } from '../../services/uploads.ts'; import { assertSafeRel, normalizeRel, resolveInside } from '../../lib/paths.ts'; import { createHash } from 'node:crypto'; @@ -530,9 +530,10 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo * The library as a tree. * * Every path here is library-relative and resolved through `resolveInside`, so - * a traversal attempt is refused before it reaches the disk. Authentication is - * the same as everywhere else; the mount is the only boundary, and it is the - * deployment's own. + * a traversal attempt is refused before it reaches the disk. Reading the tree + * is open to every user (the library browser is built on it); the writes below + * — move, rename, mkdir, delete and batch metadata — are the file manager's and + * require an administrator. */ app.get('/api/v1/library/browse', { preHandler: auth }, async (request) => { const user = currentUser(request); @@ -557,7 +558,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo }); app.post('/api/v1/library/browse/move', { preHandler: auth }, async (request) => { - currentUser(request); + requireAdmin(request); const body = (request.body ?? {}) as { paths?: unknown; target?: unknown }; const paths = stringList(body.paths, 'paths'); const target = typeof body.target === 'string' ? body.target : ''; @@ -565,7 +566,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo }); app.post('/api/v1/library/browse/rename', { preHandler: auth }, async (request) => { - currentUser(request); + requireAdmin(request); const body = (request.body ?? {}) as { path?: unknown; name?: unknown }; if (typeof body.path !== 'string' || typeof body.name !== 'string') { throw badRequest('path and name are required'); @@ -574,7 +575,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo }); app.post('/api/v1/library/browse/mkdir', { preHandler: auth }, async (request) => { - currentUser(request); + requireAdmin(request); const body = (request.body ?? {}) as { path?: unknown; name?: unknown }; const parent = typeof body.path === 'string' ? body.path : ''; if (typeof body.name !== 'string') throw badRequest('name is required'); @@ -582,7 +583,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo }); app.post('/api/v1/library/browse/delete', { preHandler: auth }, async (request) => { - currentUser(request); + requireAdmin(request); const body = (request.body ?? {}) as { paths?: unknown }; return ctx.browse.remove(stringList(body.paths, 'paths')); }); @@ -604,6 +605,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo app.post('/api/v1/library/browse/metadata', { preHandler: auth }, async (request) => { const user = currentUser(request); + requireAdmin(request); const body = (request.body ?? {}) as { paths?: unknown; fields?: unknown }; const paths = stringList(body.paths, 'paths'); const fields = (body.fields ?? {}) as Record; @@ -655,6 +657,8 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo // ---- uploads ---- + const memberUploadAllowed = (): boolean => ctx.settings!.read('library').memberUpload; + /** * Stores uploaded books in the library and indexes them. * @@ -665,7 +669,15 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo * rather than two. */ app.post('/api/v1/library/upload', { preHandler: auth }, async (request) => { - requireAdmin(request); + // Members may add books, and only add them: no overwrite and no archive + // unpacking (see `CommitOptions.booksOnly`). Changing what is already in the + // library is the file manager's, and that is admin-only. Whether members may + // upload at all is the `library.memberUpload` business setting; it is checked + // before a byte is read, so a refused upload costs nothing. + const admin = currentUser(request).role === 'admin'; + if (!admin && !memberUploadAllowed()) { + throw forbidden('member uploads are turned off', 'UPLOAD_DISABLED'); + } if (!request.isMultipart()) { throw badRequest('expected multipart/form-data', 'NOT_MULTIPART'); } @@ -722,7 +734,11 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo ensureTarget(target); if (staged.length === 0) throw badRequest('no file was uploaded', 'NO_FILES'); - return ctx.uploads.commit(staged, target, policy); + if (!admin && policy === 'overwrite') { + await ctx.uploads.discard(staged); + throw forbidden('only an administrator may overwrite library files', 'ADMIN_REQUIRED'); + } + return ctx.uploads.commit(staged, target, policy, { booksOnly: !admin }); }); /** @@ -734,9 +750,9 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo * often than this is called. */ app.get('/api/v1/library/upload', { preHandler: auth }, async (request) => { - requireAdmin(request); + const allowed = currentUser(request).role === 'admin' || memberUploadAllowed(); const listing = await ctx.browse.list(''); - return { writable: listing.writable }; + return { writable: listing.writable, allowed }; }); // ---- instance administration ---- diff --git a/server/src/services/browse.ts b/server/src/services/browse.ts index f4fd875..498e966 100644 --- a/server/src/services/browse.ts +++ b/server/src/services/browse.ts @@ -29,10 +29,10 @@ import { isPageExtension, supportedExtensions } from '../indexer/formats/index.t * before doing anything, so a read-only mount * answers a clean 403 instead of an `EROFS` stack trace. * - * The mount is the security boundary, and there is no second one: a user who can - * move a file in their library can move any file in it. That is the same trust - * level the admin scan endpoint already assumes, and inventing a per-path ACL on - * top of it would be security theatre. + * The mount is the security boundary for paths, and there is no per-path ACL on + * top of it: an administrator who can move one file can move any file in it. + * Who may write at all is decided by the routes — every mutating call here is + * admin-only, and members only add books through the upload endpoint. */ /** Directories the scanner skips. Reported to the UI rather than filtered. */ diff --git a/server/src/services/business-settings.ts b/server/src/services/business-settings.ts index 09b0be9..2ecb16f 100644 --- a/server/src/services/business-settings.ts +++ b/server/src/services/business-settings.ts @@ -12,6 +12,7 @@ export interface BusinessValues { scraping:{timeoutMs:number;retries:number}; access:{publicUrl:string;corsOrigins:string;accessTokenTtl:number;refreshTokenTtl:number}; webdav:{url:string;username:string;password:string;maxBytes:number}; + library:{memberUpload:boolean}; } export type SettingsGroup=keyof BusinessValues; type Field={key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';default:string|number|boolean;min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}; @@ -72,6 +73,9 @@ export const SETTINGS_SCHEMA:Record {key:'password',label:'密码',type:'password',default:''}, {key:'maxBytes',label:'单次上传上限(字节)',type:'number',default:2147483648,min:1,max:4294967295}, ]}, + library:{label:'书库',fields:[ + {key:'memberUpload',label:'允许成员上传书籍',type:'checkbox',default:true,help:'成员只能新增支持格式的书籍文件:不解包压缩包、不覆盖已有文件。改名、移动、删除和新建目录始终仅限管理员'}, + ]}, }; export function settingsDefaults(group:K):BusinessValues[K]{ return Object.fromEntries(SETTINGS_SCHEMA[group].fields.map(field=>[field.key,field.default])) as BusinessValues[K]; diff --git a/server/src/services/uploads.ts b/server/src/services/uploads.ts index c9b5c76..1f82086 100644 --- a/server/src/services/uploads.ts +++ b/server/src/services/uploads.ts @@ -10,6 +10,7 @@ import { resolveInside } from '../lib/paths.ts'; import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts'; import { ZipArchive, ZipError } from '../indexer/formats/zip-reader.ts'; import type { Scanner, ScanResult } from '../indexer/scanner.ts'; +import { fileHandlerForExtension } from '../indexer/formats/registry.ts'; /** * Uploading books into the library. BOOKS_DIR may be mounted read-only for a @@ -112,6 +113,16 @@ export interface UploadInput { export type ConflictPolicy = 'rename' | 'skip' | 'overwrite' | 'fail'; +export interface CommitOptions { + /** + * A member's upload: only files the scanner can open as a book are stored, and + * a zip is stored as the comic archive it names rather than unpacked. Unpacking + * writes whatever the archive holds into the library, which is file management, + * and file management is the administrator's. + */ + booksOnly?: boolean; +} + const CONFLICT_POLICIES: readonly ConflictPolicy[] = ['rename', 'skip', 'overwrite', 'fail']; export function parseConflictPolicy(value: unknown): ConflictPolicy { @@ -206,7 +217,12 @@ export class UploadService { * the library, a read-only mount — throws, and it throws before anything is * written into the library. */ - async commit(staged: StagedUpload[], target: string, policy: ConflictPolicy): Promise { + async commit( + staged: StagedUpload[], + target: string, + policy: ConflictPolicy, + { booksOnly = false }: CommitOptions = {}, + ): Promise { if (staged.length === 0) throw badRequest('no file was uploaded', 'NO_FILES'); const resolved = this.resolveTarget(target); await this.guardWritable(resolved); @@ -218,14 +234,18 @@ export class UploadService { // and then refuses has left a library the user has to reason about, and no // response can describe it. Later policies (`rename`, `skip`) resolve // conflicts rather than refusing, so they never reach this check. - if (policy === 'fail') await this.assertNoConflicts(staged, resolved); + if (policy === 'fail') await this.assertNoConflicts(staged, resolved, booksOnly); const uploaded: UploadResultItem[] = []; const skipped: Array<{ name: string; reason: string }> = []; const written: string[] = []; try { for (const item of staged) { - if (isZipContainer(item.name)) { + if (booksOnly && !isBookFile(item.name)) { + skipped.push({ name: item.originalName, reason: '不是支持的书籍格式' }); + continue; + } + if (!booksOnly && isZipContainer(item.name)) { await this.importArchive(item.stagedPath, item.name, resolved, policy, uploaded, skipped, written); } else { const placed = await this.place(item.stagedPath, resolved, item.name, item.originalName, policy); @@ -277,9 +297,10 @@ export class UploadService { * and a client uploading `系列.zip` on top of an existing `系列/` deserves the * same answer as one uploading the files directly. */ - private async assertNoConflicts(staged: StagedUpload[], target: string): Promise { + private async assertNoConflicts(staged: StagedUpload[], target: string, booksOnly: boolean): Promise { for (const item of staged) { - if (!isZipContainer(item.name)) { + if (booksOnly && !isBookFile(item.name)) continue; + if (booksOnly || !isZipContainer(item.name)) { if (await this.existingName(target, item.name)) { throw conflict(`${target === '' ? '' : `${target}/`}${item.name} is already taken`, 'DESTINATION_EXISTS'); } @@ -635,6 +656,11 @@ function isZipContainer(name: string): boolean { return ZIP_CONTAINER_EXTENSIONS.has(name.slice(dot).toLowerCase()); } +function isBookFile(name: string): boolean { + const dot = name.lastIndexOf('.'); + return dot > 0 && fileHandlerForExtension(name.slice(dot)) !== null; +} + function segmentsOf(entryName: string): string[] { return entryName.replace(/\\/g, '/').split('/').filter((segment) => segment.length > 0); } diff --git a/server/test/browse.test.ts b/server/test/browse.test.ts index 989ad82..47f98ae 100644 --- a/server/test/browse.test.ts +++ b/server/test/browse.test.ts @@ -9,7 +9,7 @@ */ import { test, before, after, describe } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, rename, rm, stat } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, rename, rm, stat, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import JSZip from 'jszip'; @@ -386,6 +386,31 @@ describe('library file management', () => { }); }); +describe('file management is admin-only', () => { + test('a member can browse but cannot move, rename, create, delete or batch-edit', async () => { + await ctx.users.createAsAdmin({ username: 'browse-member', password: 'password123', role: 'member' }); + const member = await ctx.users.login('browse-member', 'password123'); + const headers = { authorization: `Bearer ${member.accessToken}` }; + const listed = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers }); + assert.equal(listed.statusCode, 200); + + const before = await readdir(booksDir); + const writes: Array<[string, Record]> = [ + ['move', { paths: ['说明.txt'], target: '科幻' }], + ['rename', { path: '说明.txt', name: '改名.txt' }], + ['mkdir', { path: '', name: '成员文件夹' }], + ['delete', { paths: ['说明.txt'] }], + ['metadata', { paths: ['说明.txt'], fields: { title: '成员改的' } }], + ]; + for (const [action, payload] of writes) { + const res = await app.inject({ method: 'POST', url: `/api/v1/library/browse/${action}`, headers, payload }); + assert.equal(res.statusCode, 403, action); + assert.equal((res.json() as { error: { code: string } }).error.code, 'ADMIN_REQUIRED', action); + } + assert.deepEqual(await readdir(booksDir), before); + }); +}); + describe('a read-only mount', () => { test('reports itself as not writable and refuses writes with 403', async () => { const service = new BrowseService(ctx.db, ctx.config, ctx.shelf); @@ -520,12 +545,12 @@ describe('the shelf add/remove pair', () => { await writeFile(join(booksDir, `${DIR}/改日期.epub`), await makeEpub('改日期', 'urn:readd')); await ctx.scanner.scan(); const book = ctx.db.get<{ id: string }>("SELECT id FROM books WHERE title = '改日期'")!; - ctx.db.run('UPDATE user_books SET added_at = 0 WHERE book_id = ?', book.id); + ctx.db.run('UPDATE user_books SET added_at = 0 WHERE book_id = ? AND user_id = ?', book.id, userId); await act(`${DIR}/改日期.epub`, 'remove'); await act(`${DIR}/改日期.epub`, 'add'); const readded = ctx.db.get<{ added_at: number }>( - 'SELECT added_at FROM user_books WHERE book_id = ?', book.id, + 'SELECT added_at FROM user_books WHERE book_id = ? AND user_id = ?', book.id, userId, )!; assert.ok(readded.added_at > 0, '重新加入必须刷新 added_at'); }); @@ -558,7 +583,7 @@ describe('the shelf add/remove pair', () => { await ctx.scanner.scan(); assert.equal( ctx.db.get<{ hidden: number }>( - 'SELECT hidden FROM user_books WHERE book_id = ?', before.id, + 'SELECT hidden FROM user_books WHERE book_id = ? AND user_id = ?', before.id, userId, )?.hidden, 0, 'the row is still there and still not hidden', diff --git a/server/test/uploads.test.ts b/server/test/uploads.test.ts index 96bf323..42f0117 100644 --- a/server/test/uploads.test.ts +++ b/server/test/uploads.test.ts @@ -482,17 +482,63 @@ describe('uploading a book', () => { assert.equal((res.json() as { writable: boolean }).writable, true); }); - test('members cannot probe or upload, and rejected uploads leave the library unchanged', async () => { + test('members may add book files, and only add them', async () => { await ctx.users.createAsAdmin({ username: 'upload-member', password: 'password123', role: 'member' }); const member = await ctx.users.login('upload-member', 'password123'); const headers = { authorization: `Bearer ${member.accessToken}` }; - const before = await readdir(booksDir); const probe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers }); - assert.equal(probe.statusCode, 403); - const body = multipart([{ field: 'files', filename: 'member-forbidden.txt', content: Buffer.from('not allowed') }]); + assert.equal(probe.statusCode, 200); + + const zip = new JSZip(); + zip.file('inside.txt', 'from an archive'); + const body = multipart([ + { field: 'files', filename: 'member-book.txt', content: Buffer.from('a member book') }, + { field: 'files', filename: 'member-notes.docx', content: Buffer.from('not a book') }, + { field: 'files', filename: 'member-comic.zip', content: await zip.generateAsync({ type: 'nodebuffer' }) }, + ], { path: '成员上传' }); + await mkdir(join(booksDir, '成员上传'), { recursive: true }); + const upload = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...headers, 'content-type': body.contentType }, payload: body.body }); + assert.equal(upload.statusCode, 200, upload.body); + const result = upload.json() as { uploaded: Array<{ path: string; kind: string }>; skipped: Array<{ name: string }> }; + // The zip is stored as the comic archive it names, not unpacked into the library. + assert.deepEqual(result.uploaded.map((item) => item.path).sort(), ['成员上传/member-book.txt', '成员上传/member-comic.zip']); + assert.ok(result.uploaded.every((item) => item.kind === 'file')); + assert.deepEqual(result.skipped.map((item) => item.name), ['member-notes.docx']); + assert.deepEqual((await readdir(join(booksDir, '成员上传'))).sort(), ['member-book.txt', 'member-comic.zip']); + }); + + test('member uploads follow the library.memberUpload setting', async () => { + const member = await ctx.users.login('upload-member', 'password123'); + const headers = { authorization: `Bearer ${member.accessToken}` }; + const settings = ctx.settings!; + settings.save('library', { memberUpload: false }, settings.view('library').revision); + try { + const probe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers }); + assert.deepEqual(probe.json(), { writable: true, allowed: false }); + const before = await readdir(join(booksDir, '成员上传')); + const body = multipart([{ field: 'files', filename: 'turned-off.txt', content: Buffer.from('no') }], { path: '成员上传' }); + const upload = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...headers, 'content-type': body.contentType }, payload: body.body }); + assert.equal(upload.statusCode, 403); + assert.equal((upload.json() as { error: { code: string } }).error.code, 'UPLOAD_DISABLED'); + assert.deepEqual(await readdir(join(booksDir, '成员上传')), before); + // The switch is about members: an administrator still uploads. + const adminProbe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers: auth() }); + assert.deepEqual(adminProbe.json(), { writable: true, allowed: true }); + } finally { + settings.save('library', { memberUpload: true }, settings.view('library').revision); + } + }); + + test('members cannot overwrite, and a refused upload leaves the library unchanged', async () => { + const member = await ctx.users.login('upload-member', 'password123'); + const headers = { authorization: `Bearer ${member.accessToken}` }; + const before = await readdir(join(booksDir, '成员上传')); + const body = multipart([{ field: 'files', filename: 'member-book.txt', content: Buffer.from('replaced') }], { path: '成员上传', onConflict: 'overwrite' }); const upload = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...headers, 'content-type': body.contentType }, payload: body.body }); assert.equal(upload.statusCode, 403); - assert.deepEqual(await readdir(booksDir), before); + assert.equal((upload.json() as { error: { code: string } }).error.code, 'ADMIN_REQUIRED'); + assert.deepEqual(await readdir(join(booksDir, '成员上传')), before); + assert.equal(await readFile(join(booksDir, '成员上传', 'member-book.txt'), 'utf8'), 'a member book'); }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 491159e..f8061c0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -591,10 +591,14 @@ export class ReaderApi { return this.call('/api/v1/library/browse/shelf', 'POST', { ...target, action }, options); } - /** Whether the mount accepts an upload at all; asked before sending bytes. */ - async uploadProbe(options: RequestOptions = {}): Promise { - const result = await this.get<{ writable: boolean }>('/api/v1/library/upload', options); - return result.writable; + /** + * Whether the mount accepts an upload at all, and whether this user may upload + * (members only when the `library.memberUpload` setting is on); asked before + * sending bytes. + */ + async uploadProbe(options: RequestOptions = {}): Promise<{ writable: boolean; allowed: boolean }> { + const result = await this.get<{ writable: boolean; allowed?: boolean }>('/api/v1/library/upload', options); + return { writable: result.writable, allowed: result.allowed ?? true }; } /** diff --git a/web/src/features/library/pages/LibraryPage.tsx b/web/src/features/library/pages/LibraryPage.tsx index 527876d..8607e3e 100644 --- a/web/src/features/library/pages/LibraryPage.tsx +++ b/web/src/features/library/pages/LibraryPage.tsx @@ -42,16 +42,23 @@ export function LibraryBrowsePage() { const listing = useQuery({ queryKey: ['library-browse', runtime.api.baseUrl, path, page], queryFn: ({ signal }) => runtime.api.browse(path, page, { signal }) }); const books = useQuery({ queryKey: ['library-books', runtime.api.baseUrl, 'browse', path, page, search], queryFn: ({ signal }) => runtime.api.listBooks({ scope: 'library', path, page, pageSize: 60, search, sort: 'title', order: 'asc' }, { signal }) }); const navigate = (nextPath: string, nextPage = 1, nextSearch = '') => setParams({ ...(nextPath ? { path: nextPath } : {}), ...(nextSearch ? { q: nextSearch } : {}), ...(nextPage > 1 ? { page: String(nextPage) } : {}) }); + const [uploading, setUploading] = useState(false); const [feedback, setFeedback] = useState<{ text: string; error?: boolean } | null>(null); + // Members add books here; everything else that touches files is the admin-only file manager. + const upload = async (files: File[]) => { setFeedback(null); setUploading(true); try { const result = await runtime.api.upload(files, path, 'rename'); await Promise.all([queryClient.invalidateQueries({ queryKey: ['library-books'] }), queryClient.invalidateQueries({ queryKey: ['library-browse'] }), queryClient.invalidateQueries({ queryKey: ['shelf'] })]); setFeedback({ text: `已上传 ${result.uploaded.length} 本` + (result.skipped.length ? `,跳过 ${result.skipped.length} 个(${result.skipped[0]!.reason})` : ''), error: result.uploaded.length === 0 }); } catch (reason) { setFeedback({ text: reason instanceof Error ? reason.message : '上传失败,请重试', error: true }); } finally { setUploading(false); } }; + const uploadAccess = useQuery({ queryKey: ['library-upload', runtime.api.baseUrl], queryFn: ({ signal }) => runtime.api.uploadProbe({ signal }), enabled: !!role && role !== 'admin' }); + const writable = !!listing.data?.writable && !uploading; + const uploadButton = uploadAccess.data?.allowed !== true ? undefined : ; const toggle = async (book: Book) => { setBusy(book.id); try { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'add'); await queryClient.invalidateQueries({ queryKey: ['library-books'] }); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); } finally { setBusy(null); } }; const count = books.data?.total ?? 0; const pages = books.data ? Math.max(1, Math.ceil(count / books.data.pageSize)) : 1; const empty = !books.isPending && !books.error && books.data?.items.length === 0; return
- 文件管理 : undefined} /> + 文件管理 : uploadButton} /> {listing.data && listing.data.crumbs.length > 1 &&
{listing.data.crumbs.map((crumb, index) => {index > 0 && /})}
}
{ event.preventDefault(); navigate(path, 1, draft.trim()); }}>