books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 8b2dfed239
commit 9c46e281c2
65 changed files with 25709 additions and 24 deletions

View File

@@ -0,0 +1,77 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { parseRange, safeResolve, streamFile } from './files';
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'books-'));
await mkdir(path.join(root, 'library', 'a'), { recursive: true });
await writeFile(path.join(root, 'library', 'a', 'book.epub'), '0123456789');
await writeFile(path.join(root, 'secret.txt'), 'nope');
await symlink(path.join(root, 'secret.txt'), path.join(root, 'library', 'escape.epub'));
});
afterAll(() => rm(root, { recursive: true, force: true }));
describe('safeResolve', () => {
it('finds files inside the library', async () => {
expect(await safeResolve('library', 'a/book.epub', root)).toBe(
path.join(root, 'library', 'a', 'book.epub')
);
});
it.each([
'../secret.txt',
'a/../../secret.txt',
'/etc/passwd',
'escape.epub',
'a',
'',
'missing.pdf'
])('refuses %s', async (relative) => {
expect(await safeResolve('library', relative, root)).toBeNull();
});
});
describe('parseRange', () => {
it('parses single ranges', () => {
expect(parseRange('bytes=0-3', 10)).toEqual({ start: 0, end: 3 });
expect(parseRange('bytes=5-', 10)).toEqual({ start: 5, end: 9 });
expect(parseRange('bytes=-4', 10)).toEqual({ start: 6, end: 9 });
expect(parseRange('bytes=8-100', 10)).toEqual({ start: 8, end: 9 });
});
it('ignores or rejects bad ranges', () => {
expect(parseRange(null, 10)).toBeNull();
expect(parseRange('bytes=0-1,4-5', 10)).toBeNull();
expect(parseRange('bytes=10-', 10)).toBe('invalid');
expect(parseRange('bytes=5-2', 10)).toBe('invalid');
});
});
describe('streamFile', () => {
const file = () => path.join(root, 'library', 'a', 'book.epub');
it('serves the whole file', async () => {
const res = await streamFile(file(), new Request('http://x/'), {});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('application/epub+zip');
expect(await res.text()).toBe('0123456789');
});
it('serves partial content', async () => {
const res = await streamFile(
file(),
new Request('http://x/', { headers: { range: 'bytes=2-4' } }),
{}
);
expect(res.status).toBe(206);
expect(res.headers.get('content-range')).toBe('bytes 2-4/10');
expect(await res.text()).toBe('234');
});
it('answers 416 for unsatisfiable ranges', async () => {
const res = await streamFile(
file(),
new Request('http://x/', { headers: { range: 'bytes=20-' } }),
{}
);
expect(res.status).toBe(416);
});
});

View File

@@ -0,0 +1,115 @@
// Book files and covers on disk. Files are owner-only and only ever streamed through an
// authenticated endpoint; nothing here is under static/.
//
// BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/
// (books.works.cover_path is relative to it). In prod the container gets the host's books
// directory as a read-only bind mount at /books (see scripts/deploy-remote.sh).
import { createReadStream, existsSync } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
export function booksRoot(): string {
if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT;
return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books');
}
/**
* Resolves `relative` inside `<root>/<area>`, following symlinks, and refuses anything that
* escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe.
*/
export async function safeResolve(
area: 'library' | 'covers',
relative: string,
root = booksRoot()
): Promise<string | null> {
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
const base = path.join(root, area);
try {
const realBase = await realpath(base);
const real = await realpath(path.join(base, relative));
if (real !== realBase && !real.startsWith(realBase + path.sep)) return null;
return (await stat(real)).isFile() ? real : null;
} catch {
return null;
}
}
const contentTypes: Record<string, string> = {
epub: 'application/epub+zip',
pdf: 'application/pdf',
mobi: 'application/x-mobipocket-ebook',
azw: 'application/vnd.amazon.ebook',
azw3: 'application/vnd.amazon.ebook',
fb2: 'application/x-fictionbook+xml',
cbz: 'application/vnd.comicbook+zip',
djvu: 'image/vnd.djvu',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
avif: 'image/avif'
};
export function contentType(file: string): string {
return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
}
/** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */
export function parseRange(
header: string | null,
size: number
): { start: number; end: number } | 'invalid' | null {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match || (!match[1] && !match[2])) return null;
let start: number;
let end: number;
if (!match[1]) {
const suffix = Number(match[2]);
if (suffix === 0) return 'invalid';
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
}
if (start > end || start >= size) return 'invalid';
return { start, end };
}
/** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */
export async function streamFile(
file: string,
request: Request,
headers: Record<string, string>
): Promise<Response> {
const { size, mtime } = await stat(file);
const base = {
'content-type': contentType(file),
'accept-ranges': 'bytes',
'last-modified': mtime.toUTCString(),
...headers
};
const range = parseRange(request.headers.get('range'), size);
if (range === 'invalid')
return new Response(null, {
status: 416,
headers: { ...base, 'content-range': `bytes */${size}` }
});
const { start, end } = range ?? { start: 0, end: size - 1 };
const length = size === 0 ? 0 : end - start + 1;
const body =
request.method === 'HEAD' || size === 0
? null
: (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...base,
'content-length': String(length),
...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {})
}
});
}

View File

@@ -0,0 +1,373 @@
// Book tracker queries. Public callers get catalogue fields only; owner-only data (files,
// notes, highlights, sessions) is loaded separately and only after requireOwner().
import { db } from '#lib/server/db/index.js';
import {
bookFiles,
bookNotes,
bookProgress,
bookSessions,
bookWorks
} from '#lib/server/db/schema/index.js';
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
import {
formatPreference,
isReadable,
type BookCard,
type Highlight,
type LibraryStats,
type ReadingStatus
} from '#lib/books/shared.js';
import { pagesRead, SESSION_GAP_MS, type ProgressPayload } from '#lib/books/progress.js';
const TIME_ZONE = 'Europe/Amsterdam';
export const currentYear = () =>
Number(
new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE, year: 'numeric' }).format(new Date())
);
export function coverSrc(work: {
id: number;
coverPath: string | null;
coverUrl: string | null;
updatedAt?: Date | null;
}) {
if (work.coverPath)
return `/trackers/books/covers/${work.id}${work.updatedAt ? `?v=${work.updatedAt.getTime().toString(36)}` : ''}`;
if (work.coverUrl?.startsWith('https://')) return work.coverUrl;
return null;
}
const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null);
export async function libraryCards(owner: boolean): Promise<BookCard[]> {
const formats = db
.select({
workId: bookFiles.workId,
formats: sql<string[]>`array_agg(distinct lower(${bookFiles.format}))`.as('formats')
})
.from(bookFiles)
.groupBy(bookFiles.workId)
.as('f');
const rows = await db
.select({
id: bookWorks.id,
title: bookWorks.title,
subtitle: bookWorks.subtitle,
authors: bookWorks.authors,
year: bookWorks.year,
tags: bookWorks.tags,
pageCount: bookWorks.pageCount,
coverPath: bookWorks.coverPath,
coverUrl: bookWorks.coverUrl,
createdAt: bookWorks.createdAt,
updatedAt: bookWorks.updatedAt,
status: bookProgress.status,
percent: bookProgress.percent,
rating: bookProgress.rating,
lastReadAt: bookProgress.lastReadAt,
finishedAt: bookProgress.finishedAt,
formats: formats.formats
})
.from(bookWorks)
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
.leftJoin(formats, eq(formats.workId, bookWorks.id))
.orderBy(asc(bookWorks.title));
return rows.map((r) => ({
id: r.id,
title: r.title,
subtitle: r.subtitle,
authors: r.authors,
year: r.year,
tags: r.tags,
pageCount: r.pageCount,
cover: coverSrc(r),
status: r.status ?? null,
percent: r.percent ?? 0,
rating: r.rating ?? null,
lastReadAt: iso(r.lastReadAt),
finishedAt: iso(r.finishedAt),
addedAt: r.createdAt.toISOString(),
formats: owner ? (r.formats ?? []) : [],
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
}));
}
export async function libraryStats(): Promise<LibraryStats> {
const year = currentYear();
const yearStart = sql`make_timestamptz(${year}, 1, 1, 0, 0, 0, ${TIME_ZONE})`;
const [[counts], [pages]] = await Promise.all([
db
.select({
total: sql<number>`(select count(*)::int from ${bookWorks})`,
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
})
.from(bookProgress),
db
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
.from(bookSessions)
.where(gte(bookSessions.startedAt, yearStart))
]);
return {
total: counts?.total ?? 0,
finishedThisYear: counts?.finished ?? 0,
pagesThisYear: pages?.pages ?? 0,
reading: counts?.reading ?? 0,
year
};
}
export async function getWork(id: number) {
const [work] = await db.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1);
if (!work) return null;
const [progress] = await db
.select()
.from(bookProgress)
.where(eq(bookProgress.workId, id))
.limit(1);
return { work, progress: progress ?? null };
}
export async function workFiles(workId: number) {
const files = await db
.select({
id: bookFiles.id,
path: bookFiles.path,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes
})
.from(bookFiles)
.where(eq(bookFiles.workId, workId));
const rank = (f: string) => {
const i = formatPreference.indexOf(f.toLowerCase());
return i === -1 ? 99 : i;
};
return files
.map((f) => ({ ...f, format: f.format.toLowerCase(), readable: isReadable(f.format) }))
.sort((a, b) => rank(a.format) - rank(b.format));
}
export async function workNotes(workId: number): Promise<Highlight[]> {
const rows = await db
.select()
.from(bookNotes)
.where(eq(bookNotes.workId, workId))
.orderBy(desc(bookNotes.createdAt));
return rows.map((n) => ({
id: n.id,
fileId: n.fileId,
locator: n.locator,
page: n.page,
highlight: n.highlight,
note: n.note,
color: n.color,
createdAt: n.createdAt.toISOString()
}));
}
export async function workSessions(workId: number) {
const rows = await db
.select()
.from(bookSessions)
.where(eq(bookSessions.workId, workId))
.orderBy(desc(bookSessions.startedAt))
.limit(200);
return rows.map((s) => ({
id: s.id,
startedAt: s.startedAt.toISOString(),
endedAt: iso(s.endedAt),
startPercent: s.startPercent,
endPercent: s.endPercent,
startPage: s.startPage,
endPage: s.endPage,
pagesRead: s.pagesRead,
source: s.source,
note: s.note
}));
}
/** Sets the reading status, keeping the start/finish dates and percent consistent. */
export async function setStatus(workId: number, status: ReadingStatus) {
const now = new Date();
await db
.insert(bookProgress)
.values({
workId,
status,
startedAt: status === 'want_to_read' ? null : now,
finishedAt: status === 'finished' ? now : null,
percent: status === 'finished' ? 100 : 0
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
status,
startedAt:
status === 'want_to_read'
? sql`${bookProgress.startedAt}`
: sql`coalesce(${bookProgress.startedAt}, ${now.toISOString()}::timestamptz)`,
finishedAt:
status === 'finished'
? sql`coalesce(${bookProgress.finishedAt}, ${now.toISOString()}::timestamptz)`
: null,
percent: status === 'finished' ? 100 : sql`${bookProgress.percent}`
}
});
}
export async function setRating(workId: number, rating: number | null) {
await db
.insert(bookProgress)
.values({ workId, rating })
.onConflictDoUpdate({ target: bookProgress.workId, set: { rating } });
}
/** Manual progress from the detail page (percent and/or page). */
export async function setManualProgress(workId: number, percent: number, page: number | null) {
await db
.insert(bookProgress)
.values({
workId,
percent,
page,
status: percent > 0 ? 'reading' : 'want_to_read',
startedAt: percent > 0 ? new Date() : null
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
percent,
page,
status: sql`case when ${bookProgress.status} = 'want_to_read' and ${percent} > 0 then 'reading'::books.reading_status else ${bookProgress.status} end`,
startedAt: sql`coalesce(${bookProgress.startedAt}, case when ${percent} > 0 then now() end)`
}
});
}
/**
* Saves the reader position and keeps a reading session going. Returns the session id the
* reader should send back with its next save. A save more than SESSION_GAP_MS after the
* last one starts a new session.
*/
export async function saveProgress(workId: number, input: ProgressPayload) {
return db.transaction(async (tx) => {
const [work] = await tx
.select({ pageCount: bookWorks.pageCount })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
if (!work) return null;
if (input.fileId) {
const [file] = await tx
.select({ id: bookFiles.id })
.from(bookFiles)
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
.limit(1);
if (!file) return null;
}
const [before] = await tx
.select()
.from(bookProgress)
.where(eq(bookProgress.workId, workId))
.limit(1);
const now = new Date();
await tx
.insert(bookProgress)
.values({
workId,
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
percent: input.percent,
status: 'reading',
startedAt: now,
lastReadAt: now
})
.onConflictDoUpdate({
target: bookProgress.workId,
set: {
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
percent: input.percent,
lastReadAt: now,
status: sql`case when ${bookProgress.status} = 'want_to_read' then 'reading'::books.reading_status else ${bookProgress.status} end`,
startedAt: sql`coalesce(${bookProgress.startedAt}, now())`
}
});
const pageCount = input.pageCount ?? work.pageCount;
let session = input.sessionId
? (
await tx
.select()
.from(bookSessions)
.where(and(eq(bookSessions.id, input.sessionId), eq(bookSessions.workId, workId)))
.limit(1)
)[0]
: undefined;
const last = session?.endedAt ?? session?.startedAt;
if (session && last && now.getTime() - last.getTime() > SESSION_GAP_MS) session = undefined;
if (!session) {
[session] = await tx
.insert(bookSessions)
.values({
workId,
fileId: input.fileId ?? null,
startedAt: now,
endedAt: now,
startPercent: before?.percent ?? input.percent,
endPercent: input.percent,
startPage: before?.page ?? input.page ?? null,
endPage: input.page ?? null,
pagesRead: 0,
source: 'reader'
})
.returning();
} else {
const startPage = session.startPage ?? input.page ?? null;
await tx
.update(bookSessions)
.set({
endedAt: now,
endPercent: input.percent,
endPage: input.page ?? null,
startPage,
pagesRead: pagesRead({
startPage: input.page != null ? startPage : null,
endPage: input.page,
startPercent: session.startPercent,
endPercent: input.percent,
pageCount
})
})
.where(eq(bookSessions.id, session.id));
}
return { sessionId: session.id };
});
}
export async function logManualSession(
workId: number,
input: {
startedAt: Date;
endedAt: Date | null;
startPage: number | null;
endPage: number | null;
startPercent: number | null;
endPercent: number | null;
note: string | null;
}
) {
const [work] = await db
.select({ pageCount: bookWorks.pageCount })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
await db.insert(bookSessions).values({
workId,
...input,
pagesRead: pagesRead({ ...input, pageCount: work?.pageCount }),
source: 'manual'
});
}

View File

@@ -0,0 +1,84 @@
// Highlights and notes (books.notes). A row with `highlight` is a highlight from the reader
// (optionally with a note); a row with only `note` is a free-form note on the book.
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';
import { highlightColors, type Highlight } from '#lib/books/shared.js';
export const notePayload = z
.object({
fileId: z.number().int().positive().nullish(),
locator: z.string().max(4000).nullish(),
page: z.number().int().min(0).max(100000).nullish(),
highlight: z.string().trim().max(20000).nullish(),
note: z.string().trim().max(20000).nullish(),
color: z.enum(highlightColors).nullish()
})
.refine((n) => !!(n.highlight || n.note), { message: 'Empty note' });
export const noteUpdate = z.object({
note: z.string().trim().max(20000).nullable().optional(),
color: z.enum(highlightColors).nullable().optional()
});
const toHighlight = (n: typeof bookNotes.$inferSelect): Highlight => ({
id: n.id,
fileId: n.fileId,
locator: n.locator,
page: n.page,
highlight: n.highlight,
note: n.note,
color: n.color,
createdAt: n.createdAt.toISOString()
});
export async function createNote(workId: number, input: z.infer<typeof notePayload>) {
const [work] = await db
.select({ id: bookWorks.id })
.from(bookWorks)
.where(eq(bookWorks.id, workId))
.limit(1);
if (!work) return null;
if (input.fileId) {
const [file] = await db
.select({ id: bookFiles.id })
.from(bookFiles)
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
.limit(1);
if (!file) return null;
}
const [row] = await db
.insert(bookNotes)
.values({
workId,
fileId: input.fileId ?? null,
locator: input.locator ?? null,
page: input.page ?? null,
highlight: input.highlight || null,
note: input.note || null,
color: input.highlight ? (input.color ?? 'yellow') : null
})
.returning();
return toHighlight(row);
}
export async function updateNote(workId: number, id: number, input: z.infer<typeof noteUpdate>) {
const set: Partial<typeof bookNotes.$inferInsert> = {};
if (input.note !== undefined) set.note = input.note || null;
if (input.color !== undefined) set.color = input.color;
if (!Object.keys(set).length) return null;
const [row] = await db
.update(bookNotes)
.set(set)
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
.returning();
return row ? toHighlight(row) : null;
}
export async function deleteNote(workId: number, id: number) {
const rows = await db
.delete(bookNotes)
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
.returning({ id: bookNotes.id });
return rows.length > 0;
}

View File

@@ -7,6 +7,7 @@ import {
jsonb,
primaryKey,
real,
smallint,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
@@ -25,6 +26,14 @@ export const bookMetadataStatus = booksSchema.enum('metadata_status', [
'manual' // edited by hand; enrichment never overwrites it
]);
// Reading state per work (on books.progress), shown publicly on /trackers/books.
export const bookReadingStatus = booksSchema.enum('reading_status', [
'want_to_read',
'reading',
'finished',
'abandoned'
]);
export const bookWorks = booksSchema.table(
'works',
{
@@ -128,11 +137,16 @@ export const bookProgress = booksSchema.table(
percent: real('percent').notNull().default(0),
startedAt: timestamptz('started_at'),
finishedAt: timestamptz('finished_at'),
status: bookReadingStatus('status').notNull().default('want_to_read'),
rating: smallint('rating'), // 1-5 stars
lastReadAt: timestamptz('last_read_at'), // last time the reader saved a position
...timestamps
},
(t) => [
uniqueIndex('progress_work_id_key').on(t.workId),
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
check('progress_percent_check', sql`${t.percent} between 0 and 100`),
check('progress_rating_check', sql`${t.rating} is null or ${t.rating} between 1 and 5`),
index('progress_status_idx').on(t.status)
]
);
@@ -154,9 +168,42 @@ export const bookNotes = booksSchema.table(
(t) => [index('notes_work_id_idx').on(t.workId)]
);
// One reading session: opened in the reader (source 'reader') or logged by hand ('manual').
export const bookSessions = booksSchema.table(
'sessions',
{
id: id(),
workId: integer('work_id')
.notNull()
.references(() => bookWorks.id, { onDelete: 'cascade' }),
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
startedAt: timestamptz('started_at').notNull().defaultNow(),
endedAt: timestamptz('ended_at'),
startPercent: real('start_percent'),
endPercent: real('end_percent'),
startPage: integer('start_page'),
endPage: integer('end_page'),
pagesRead: integer('pages_read'), // derived on save: page delta, or percent delta x page_count
source: text('source').notNull().default('reader'),
note: text('note'),
...timestamps
},
(t) => [
index('sessions_work_id_idx').on(t.workId),
index('sessions_started_at_idx').on(t.startedAt),
check(
'sessions_percent_check',
sql`(${t.startPercent} is null or ${t.startPercent} between 0 and 100) and (${t.endPercent} is null or ${t.endPercent} between 0 and 100)`
),
check('sessions_end_check', sql`${t.endedAt} is null or ${t.endedAt} >= ${t.startedAt}`)
]
);
export type BookWork = typeof bookWorks.$inferSelect;
export type NewBookWork = typeof bookWorks.$inferInsert;
export type BookFile = typeof bookFiles.$inferSelect;
export type BookShelf = typeof bookShelves.$inferSelect;
export type BookProgress = typeof bookProgress.$inferSelect;
export type BookNote = typeof bookNotes.$inferSelect;
export type BookSession = typeof bookSessions.$inferSelect;
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isOwner, isOwnerOnlyPath } from './owner';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
@@ -17,4 +17,22 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
expect(isOwnerOnlyPath('/trackers')).toBe(false);
});
it('gates the book reader, files and APIs but not the catalogue', () => {
expect(isOwnerOnlyPath('/trackers/books/files/12')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/read')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/progress')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books/12/notes/3')).toBe(true);
expect(isOwnerOnlyPath('/trackers/books')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
});
it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true);
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
expect(isOwnerWritePath('/lists/books')).toBe(false);
});
});

View File

@@ -19,9 +19,32 @@ export function requireOwner(event: OwnerEvent) {
}
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
/**
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
* APIs. /trackers/books and /trackers/books/<id> themselves are public catalogue pages.
*/
export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes)(\/|$)/] as const;
/**
* Areas where only the owner may write: any non-GET request from anyone else is refused
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
*/
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
export function isOwnerOnlyPath(pathname: string): boolean {
const path = pathname.toLowerCase().replace(/\/+$/, '');
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
const path = normalise(pathname);
return (
ownerOnlyPrefixes.some((prefix) => under(path, prefix)) ||
ownerOnlyPatterns.some((pattern) => pattern.test(path))
);
}
export function isOwnerWritePath(pathname: string): boolean {
const path = normalise(pathname);
return ownerWritePrefixes.some((prefix) => under(path, prefix));
}