chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

40
apps/api/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "@readabook/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"lint": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@nestjs/common": "^11.0.7",
"@nestjs/core": "^11.0.7",
"@nestjs/platform-fastify": "^11.0.7",
"@readabook/shared": "workspace:*",
"adm-zip": "^0.5.16",
"argon2": "^0.41.1",
"better-sqlite3": "^11.8.1",
"drizzle-orm": "^0.39.3",
"fast-xml-parser": "^4.5.1",
"fastify": "^5.2.1",
"jose": "^5.9.6",
"mime-types": "^2.1.35",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.12",
"@types/mime-types": "^2.1.4",
"tsx": "^4.19.2",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import {
CreateLibraryDto,
CreateLibrarySchema,
CreateUserDto,
CreateUserSchema,
UpdateLibraryDto,
UpdateLibrarySchema,
UpdateUserDto,
UpdateUserSchema
} from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { Roles } from "../auth/roles.decorator.js";
import { RolesGuard } from "../auth/roles.guard.js";
import { AuthService } from "../auth/auth.service.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { JobsService } from "../jobs/jobs.service.js";
import { LibrariesService } from "../libraries/libraries.service.js";
import { ScannerService } from "../scanner/scanner.service.js";
@Controller("admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
export class AdminController {
constructor(
private readonly auth: AuthService,
private readonly libraries: LibrariesService,
private readonly jobs: JobsService,
private readonly scanner: ScannerService
) {}
@Get("users")
users() {
return this.auth.listUsers();
}
@Post("users")
createUser(@Body(new ZodValidationPipe(CreateUserSchema)) body: CreateUserDto) {
return this.auth.createUser(body);
}
@Patch("users/:id")
updateUser(@Param("id") id: string, @Body(new ZodValidationPipe(UpdateUserSchema)) body: UpdateUserDto) {
return this.auth.updateUser(Number(id), body);
}
@Delete("users/:id")
deleteUser(@Param("id") id: string) {
this.auth.deleteUser(Number(id));
return { ok: true };
}
@Get("libraries")
listLibraries() {
return this.libraries.list();
}
@Post("libraries")
createLibrary(@Body(new ZodValidationPipe(CreateLibrarySchema)) body: CreateLibraryDto) {
return this.libraries.create(body);
}
@Patch("libraries/:id")
updateLibrary(@Param("id") id: string, @Body(new ZodValidationPipe(UpdateLibrarySchema)) body: UpdateLibraryDto) {
return this.libraries.update(Number(id), body);
}
@Delete("libraries/:id")
deleteLibrary(@Param("id") id: string) {
this.libraries.delete(Number(id));
return { ok: true };
}
@Post("libraries/:id/scan")
scanLibrary(@Param("id") id: string) {
return this.scanner.enqueueLibraryScan(Number(id));
}
@Get("jobs")
listJobs() {
return this.jobs.list();
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { LibrariesService } from "../libraries/libraries.service.js";
import { ScannerModule } from "../scanner/scanner.module.js";
import { AdminController } from "./admin.controller.js";
@Module({
imports: [AuthModule, DatabaseModule, JobsModule, ScannerModule],
controllers: [AdminController],
providers: [LibrariesService]
})
export class AdminModule {}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AdminModule } from "./admin/admin.module.js";
import { AuthModule } from "./auth/auth.module.js";
import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
import "@fastify/cookie";
import { FastifyReply } from "fastify";
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { CurrentUser, CurrentUserParam } from "./current-user.js";
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body);
}
@Post("login")
async login(@Body(new ZodValidationPipe(LoginSchema)) body: LoginDto, @Res({ passthrough: true }) reply: FastifyReply) {
const result = await this.auth.login(body);
reply.setCookie(this.auth.cookieName, result.token, {
httpOnly: true,
sameSite: "lax",
secure: this.auth.cookieSecure,
path: "/",
maxAge: 7 * 24 * 60 * 60
});
return { user: result.user };
}
@Post("logout")
logout(@Res({ passthrough: true }) reply: FastifyReply) {
reply.clearCookie(this.auth.cookieName, { path: "/" });
return { ok: true };
}
@Get("me")
@UseGuards(AuthGuard)
me(@CurrentUserParam() user: CurrentUser) {
return { user };
}
}

View File

@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service.js";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.cookies?.[this.auth.cookieName];
if (!token) {
throw new UnauthorizedException("Authentication required");
}
request.user = await this.auth.verifySession(token);
return true;
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { AuthController } from "./auth.controller.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js";
import { RolesGuard } from "./roles.guard.js";
@Module({
imports: [DatabaseModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard, RolesGuard],
exports: [AuthService, AuthGuard, RolesGuard]
})
export class AuthModule {}

View File

@ -0,0 +1,128 @@
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2";
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js";
@Injectable()
export class AuthService {
readonly cookieName: string;
private readonly secret: Uint8Array;
constructor(private readonly database: DatabaseService) {
this.cookieName = database.config.cookieName;
this.secret = new TextEncoder().encode(database.config.jwtSecret);
}
get cookieSecure(): boolean {
return this.database.config.cookieSecure;
}
async bootstrapAdmin(input: BootstrapAdminDto) {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
if (existing) {
throw new ConflictException("Bootstrap already completed");
}
return this.createUser({ ...input, role: "admin" });
}
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
throw new UnauthorizedException("Invalid credentials");
}
const sessionUser = { id: user.id, email: user.email, role: user.role };
return { token: await this.signSession(sessionUser), user: sessionUser };
}
async verifySession(token: string): Promise<CurrentUser> {
try {
const { payload } = await jwtVerify(token, this.secret);
const id = Number(payload.sub);
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
if (!user) {
throw new UnauthorizedException("Invalid session");
}
return { id: user.id, email: user.email, role: user.role };
} catch {
throw new UnauthorizedException("Invalid session");
}
}
listUsers() {
return this.database.db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.from(users)
.all();
}
async createUser(input: CreateUserDto) {
const now = this.database.now();
const passwordHash = await argon2.hash(input.password);
try {
const user = this.database.db
.insert(users)
.values({
email: input.email.toLowerCase(),
name: input.name ?? null,
passwordHash,
role: input.role,
createdAt: now,
updatedAt: now
})
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
return user;
} catch (error) {
throw new ConflictException("Email already exists");
}
}
async updateUser(id: number, input: UpdateUserDto) {
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
if (input.email) values.email = input.email.toLowerCase();
if (input.name !== undefined) values.name = input.name;
if (input.role) values.role = input.role;
if (input.password) values.passwordHash = await argon2.hash(input.password);
return this.database.db
.update(users)
.set(values)
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt
})
.get();
}
deleteUser(id: number): void {
this.database.db.delete(users).where(eq(users.id, id)).run();
}
private async signSession(user: CurrentUser): Promise<string> {
return new SignJWT({ email: user.email, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setSubject(String(user.id))
.setIssuedAt()
.setExpirationTime("7d")
.sign(this.secret);
}
}

View File

@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export type CurrentUser = {
id: number;
email: string;
role: "admin" | "user";
};
export const CurrentUserParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): CurrentUser => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Array<"admin" | "user">) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "./roles.decorator.js";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Array<"admin" | "user">>(ROLES_KEY, [
context.getHandler(),
context.getClass()
]);
if (!roles?.length) {
return true;
}
const user = context.switchToHttp().getRequest().user;
if (user && roles.includes(user.role)) {
return true;
}
throw new ForbiddenException("Insufficient role");
}
}

View File

@ -0,0 +1,43 @@
import { Controller, Get, Param, Query, Res, UseGuards } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { BooksService } from "./books.service.js";
@Controller("books")
@UseGuards(AuthGuard)
export class BooksController {
constructor(private readonly books: BooksService) {}
@Get()
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query);
}
@Get("search")
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.get(Number(id));
}
@Get(":id/file")
file(@Param("id") id: string, @Res() reply: FastifyReply) {
const { book, stream } = this.books.streamFile(Number(id));
reply.header("Content-Type", lookup(book.filePath) || "application/octet-stream");
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(book.title)}.${book.format}"`);
return reply.send(stream);
}
@Get(":id/cover")
cover(@Param("id") id: string, @Res() reply: FastifyReply) {
const { coverPath, stream } = this.books.streamCover(Number(id));
reply.header("Content-Type", lookup(coverPath) || "image/jpeg");
return reply.send(stream);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { BooksController } from "./books.controller.js";
import { BooksService } from "./books.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BooksController],
providers: [BooksService],
exports: [BooksService]
})
export class BooksModule {}

View File

@ -0,0 +1,97 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync } from "node:fs";
import { and, eq, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
@Injectable()
export class BooksService {
constructor(private readonly database: DatabaseService) {}
list(query: BookQueryDto) {
const filters = [];
if (query.format) filters.push(eq(books.format, query.format));
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
if (query.q) {
return this.search(query.q, query.limit, query.offset);
}
return this.database.db
.select()
.from(books)
.where(filters.length ? and(...filters) : undefined)
.orderBy(books.title)
.limit(query.limit)
.offset(query.offset)
.all();
}
search(q: string, limit = 50, offset = 0) {
const rows = this.database.sqlite
.prepare(
`
SELECT books.*
FROM book_fts
JOIN books ON books.id = book_fts.rowid
WHERE book_fts MATCH ?
ORDER BY bm25(book_fts)
LIMIT ? OFFSET ?
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
}
get(id: number) {
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
return book;
}
streamFile(id: number) {
const book = this.get(id);
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
return { book, stream: createReadStream(book.filePath) };
}
streamCover(id: number) {
const book = this.get(id);
if (!book.coverPath || !existsSync(book.coverPath)) {
throw new NotFoundException("Cover not found");
}
return { book, stream: createReadStream(book.coverPath), coverPath: book.coverPath };
}
count() {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
}
}
function mapBookRow(row: Record<string, unknown>) {
return {
id: Number(row.id),
libraryId: Number(row.library_id),
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: nullable(row.published_date),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
function nullable(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}

View File

@ -0,0 +1,18 @@
import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
import { ZodSchema } from "zod";
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: "Validation failed",
issues: result.error.issues
});
}
return result.data;
}
}

View File

@ -0,0 +1,34 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
export type AppConfig = {
nodeEnv: string;
host: string;
port: number;
databasePath: string;
storageDir: string;
jwtSecret: string;
cookieName: string;
cookieSecure: boolean;
openLibraryEnabled: boolean;
};
export function loadConfig(): AppConfig {
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
mkdirSync(dirname(databasePath), { recursive: true });
mkdirSync(storageDir, { recursive: true });
return {
nodeEnv: process.env.NODE_ENV ?? "development",
host: process.env.HOST ?? "0.0.0.0",
port: Number(process.env.PORT ?? 3000),
databasePath,
storageDir,
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true",
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
};
}

View File

@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DatabaseService } from "./database.service.js";
@Module({
providers: [DatabaseService],
exports: [DatabaseService]
})
export class DatabaseModule {}

View File

@ -0,0 +1,124 @@
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js";
import * as schema from "./schema.js";
@Injectable()
export class DatabaseService implements OnModuleDestroy {
readonly config: AppConfig;
readonly sqlite: Database.Database;
readonly db: BetterSQLite3Database<typeof schema>;
constructor() {
this.config = loadConfig();
this.sqlite = new Database(this.config.databasePath);
this.sqlite.pragma("journal_mode = WAL");
this.sqlite.pragma("foreign_keys = ON");
this.sqlite.pragma("busy_timeout = 5000");
this.db = drizzle(this.sqlite, { schema });
this.migrate();
}
onModuleDestroy(): void {
this.sqlite.close();
}
now(): string {
return new Date().toISOString();
}
private migrate(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
detail TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title,
author,
description,
isbn,
content='books',
content_rowid='id'
);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
}

View File

@ -0,0 +1,77 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
name: text("name"),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["admin", "user"] }).notNull().default("user"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ emailIdx: uniqueIndex("users_email_unique").on(table.email) })
);
export const libraries = sqliteTable("libraries", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
path: text("path").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const books = sqliteTable(
"books",
{
id: integer("id").primaryKey({ autoIncrement: true }),
libraryId: integer("library_id")
.notNull()
.references(() => libraries.id, { onDelete: "cascade" }),
title: text("title").notNull(),
author: text("author"),
description: text("description"),
isbn: text("isbn"),
language: text("language"),
publisher: text("publisher"),
publishedDate: text("published_date"),
format: text("format", { enum: ["epub", "pdf"] }).notNull(),
filePath: text("file_path").notNull(),
coverPath: text("cover_path"),
fileSize: integer("file_size").notNull(),
fileMtime: text("file_mtime").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
);
export const progress = sqliteTable(
"progress",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
bookId: integer("book_id")
.notNull()
.references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(),
percent: integer("percent").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({ userBookIdx: uniqueIndex("progress_user_book_unique").on(table.userId, table.bookId) })
);
export const jobs = sqliteTable("jobs", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").notNull(),
status: text("status", { enum: ["queued", "running", "succeeded", "failed"] }).notNull(),
detail: text("detail"),
error: text("error"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});

View File

@ -0,0 +1,22 @@
import { Controller, Get } from "@nestjs/common";
import { BooksService } from "./books/books.service.js";
import { DatabaseService } from "./database/database.service.js";
@Controller()
export class HealthController {
constructor(
private readonly database: DatabaseService,
private readonly books: BooksService
) {}
@Get("healthz")
healthz() {
this.database.sqlite.prepare("SELECT 1").get();
return {
status: "ok",
database: "ok",
books: this.books.count(),
timestamp: this.database.now()
};
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { JobsService } from "./jobs.service.js";
@Module({
imports: [DatabaseModule],
providers: [JobsService],
exports: [JobsService]
})
export class JobsModule {}

View File

@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { jobs } from "../database/schema.js";
@Injectable()
export class JobsService {
constructor(private readonly database: DatabaseService) {}
create(type: string, detail?: string) {
const now = this.database.now();
return this.database.db
.insert(jobs)
.values({ type, status: "queued", detail: detail ?? null, error: null, createdAt: now, updatedAt: now })
.returning()
.get();
}
markRunning(id: number, detail?: string): void {
this.database.db
.update(jobs)
.set({ status: "running", detail: detail ?? null, updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
markSucceeded(id: number, detail?: string): void {
this.database.db
.update(jobs)
.set({ status: "succeeded", detail: detail ?? null, error: null, updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
markFailed(id: number, error: unknown): void {
this.database.db
.update(jobs)
.set({ status: "failed", error: error instanceof Error ? error.message : String(error), updatedAt: this.database.now() })
.where(eq(jobs.id, id))
.run();
}
list(limit = 50) {
return this.database.db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit).all();
}
}

View File

@ -0,0 +1,65 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { accessSync, constants, realpathSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { CreateLibraryDto, UpdateLibraryDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { libraries } from "../database/schema.js";
@Injectable()
export class LibrariesService {
constructor(private readonly database: DatabaseService) {}
list() {
return this.database.db.select().from(libraries).all();
}
get(id: number) {
const library = this.database.db.select().from(libraries).where(eq(libraries.id, id)).get();
if (!library) {
throw new NotFoundException("Library not found");
}
return library;
}
create(input: CreateLibraryDto) {
const path = this.validatePath(input.path);
const now = this.database.now();
return this.database.db
.insert(libraries)
.values({ name: input.name, path, enabled: input.enabled, createdAt: now, updatedAt: now })
.returning()
.get();
}
update(id: number, input: UpdateLibraryDto) {
const values: Partial<typeof libraries.$inferInsert> = { updatedAt: this.database.now() };
if (input.name) values.name = input.name;
if (input.path) values.path = this.validatePath(input.path);
if (input.enabled !== undefined) values.enabled = input.enabled;
const library = this.database.db.update(libraries).set(values).where(eq(libraries.id, id)).returning().get();
if (!library) {
throw new NotFoundException("Library not found");
}
return library;
}
delete(id: number): void {
this.database.db.delete(libraries).where(eq(libraries.id, id)).run();
}
private validatePath(input: string): string {
const resolved = resolve(input);
try {
accessSync(resolved, constants.R_OK);
const stats = statSync(resolved);
if (!stats.isDirectory()) {
throw new BadRequestException("Library path must be a directory");
}
return realpathSync(resolved);
} catch (error) {
if (error instanceof BadRequestException) throw error;
throw new BadRequestException("Library path is not readable");
}
}
}

20
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import "reflect-metadata";
import cookie from "@fastify/cookie";
import "@fastify/cookie";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { AppModule } from "./app.module.js";
import { loadConfig } from "./config/env.js";
async function bootstrap() {
const config = loadConfig();
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter({ logger: true }));
await app.register(cookie as never, { secret: config.jwtSecret });
app.enableCors({
origin: true,
credentials: true
});
await app.listen(config.port, config.host);
}
void bootstrap();

View File

@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { UpdateProgressDto, UpdateProgressSchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { ProgressService } from "./progress.service.js";
@Controller("progress")
@UseGuards(AuthGuard)
export class ProgressController {
constructor(private readonly progress: ProgressService) {}
@Get("continue")
continue(@CurrentUserParam() user: CurrentUser) {
return this.progress.continueReading(user.id);
}
@Get(":bookId")
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
return this.progress.get(user.id, Number(bookId));
}
@Put(":bookId")
update(
@CurrentUserParam() user: CurrentUser,
@Param("bookId") bookId: string,
@Body(new ZodValidationPipe(UpdateProgressSchema)) body: UpdateProgressDto
) {
return this.progress.upsert(user.id, Number(bookId), body);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { ProgressController } from "./progress.controller.js";
import { ProgressService } from "./progress.service.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [ProgressController],
providers: [ProgressService]
})
export class ProgressModule {}

View File

@ -0,0 +1,67 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq, sql } from "drizzle-orm";
import { UpdateProgressDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, progress } from "../database/schema.js";
@Injectable()
export class ProgressService {
constructor(private readonly database: DatabaseService) {}
upsert(userId: number, bookId: number, input: UpdateProgressDto) {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
const now = this.database.now();
this.database.sqlite
.prepare(
`
INSERT INTO progress(user_id, book_id, locator, percent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator,
percent = excluded.percent,
updated_at = excluded.updated_at
`
)
.run(userId, bookId, input.locator, Math.round(input.percent), now, now);
return this.get(userId, bookId);
}
get(userId: number, bookId: number) {
const row = this.database.db
.select({
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
updatedAt: progress.updatedAt
})
.from(progress)
.where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`)
.get();
if (!row) {
throw new NotFoundException("Progress not found");
}
return row;
}
continueReading(userId: number) {
return this.database.db
.select({
book: books,
progress: {
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
updatedAt: progress.updatedAt
}
})
.from(progress)
.innerJoin(books, eq(progress.bookId, books.id))
.where(eq(progress.userId, userId))
.orderBy(desc(progress.updatedAt))
.limit(20)
.all();
}
}

View File

@ -0,0 +1,18 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { extractMetadata } from "./metadata.js";
describe("pdf metadata extraction", () => {
it("falls back to file name and reads simple PDF info fields", () => {
const dir = mkdtempSync(join(tmpdir(), "readabook-"));
const file = join(dir, "Example.pdf");
writeFileSync(file, "%PDF-1.4\n1 0 obj << /Title (My Book) /Author (Ada) >> endobj");
const metadata = extractMetadata(file, dir);
expect(metadata.title).toBe("My Book");
expect(metadata.author).toBe("Ada");
});
});

View File

@ -0,0 +1,149 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
export type BookMetadata = {
title: string;
author: string | null;
description: string | null;
isbn: string | null;
language: string | null;
publisher: string | null;
publishedDate: string | null;
coverPath: string | null;
};
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
textNodeName: "#text"
});
export function extractMetadata(filePath: string, storageDir: string): BookMetadata {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") {
return extractEpubMetadata(filePath, storageDir);
}
return extractPdfMetadata(filePath);
}
function extractEpubMetadata(filePath: string, storageDir: string): BookMetadata {
const zip = new AdmZip(filePath);
const containerXml = zip.readAsText("META-INF/container.xml");
const container = xmlParser.parse(containerXml);
const rootfile = container?.container?.rootfiles?.rootfile;
const opfPath = Array.isArray(rootfile) ? rootfile[0]?.["@_full-path"] : rootfile?.["@_full-path"];
if (!opfPath) {
return fallbackMetadata(filePath);
}
const opf = xmlParser.parse(zip.readAsText(opfPath));
const metadata = opf?.package?.metadata ?? {};
const manifest = opf?.package?.manifest?.item;
const opfDir = dirname(opfPath) === "." ? "" : dirname(opfPath);
const title = firstText(metadata["dc:title"]) ?? basename(filePath, extname(filePath));
const author = firstText(metadata["dc:creator"]);
const isbn = findIsbn(metadata["dc:identifier"]);
const coverHref = findCoverHref(manifest, metadata.meta);
const coverPath = coverHref ? extractCover(zip, join(opfDir, coverHref), filePath, storageDir) : null;
return {
title,
author,
description: firstText(metadata["dc:description"]),
isbn,
language: firstText(metadata["dc:language"]),
publisher: firstText(metadata["dc:publisher"]),
publishedDate: firstText(metadata["dc:date"]),
coverPath
};
}
function extractPdfMetadata(filePath: string): BookMetadata {
const buffer = readFileSync(filePath);
const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1");
const title = decodePdfString(matchPdfInfo(head, "Title")) ?? basename(filePath, extname(filePath));
const author = decodePdfString(matchPdfInfo(head, "Author"));
return {
title,
author,
description: decodePdfString(matchPdfInfo(head, "Subject")),
isbn: findIsbnInText(head),
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
}
function fallbackMetadata(filePath: string): BookMetadata {
return {
title: basename(filePath, extname(filePath)),
author: null,
description: null,
isbn: null,
language: null,
publisher: null,
publishedDate: null,
coverPath: null
};
}
function firstText(value: unknown): string | null {
if (!value) return null;
const first = Array.isArray(value) ? value[0] : value;
if (typeof first === "string") return first.trim() || null;
if (typeof first === "object" && first !== null && "#text" in first) {
const text = String((first as Record<string, unknown>)["#text"]).trim();
return text || null;
}
return null;
}
function findIsbn(value: unknown): string | null {
const values = Array.isArray(value) ? value : value ? [value] : [];
for (const candidate of values) {
const text = firstText(candidate);
const isbn = text ? findIsbnInText(text) : null;
if (isbn) return isbn;
}
return null;
}
function findIsbnInText(text: string): string | null {
const match = text.match(/(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/i);
return match ? match[0].replace(/[-\s]/g, "").toUpperCase() : null;
}
function findCoverHref(manifestValue: unknown, metaValue: unknown): string | null {
const manifest = Array.isArray(manifestValue) ? manifestValue : manifestValue ? [manifestValue] : [];
const metas = Array.isArray(metaValue) ? metaValue : metaValue ? [metaValue] : [];
const coverId = metas.find((meta) => meta?.["@_name"] === "cover")?.["@_content"];
const cover =
manifest.find((item) => coverId && item?.["@_id"] === coverId) ??
manifest.find((item) => String(item?.["@_properties"] ?? "").includes("cover-image")) ??
manifest.find((item) => String(item?.["@_media-type"] ?? "").startsWith("image/"));
return cover?.["@_href"] ?? null;
}
function extractCover(zip: AdmZip, coverPathInZip: string, filePath: string, storageDir: string): string | null {
const entry = zip.getEntry(coverPathInZip.replace(/\\/g, "/"));
if (!entry) return null;
const extension = extname(entry.entryName) || ".jpg";
const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 24);
const target = join(storageDir, "covers", `${hash}${extension}`);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, entry.getData());
return target;
}
function matchPdfInfo(text: string, key: string): string | null {
return text.match(new RegExp(`/${key}\\s*\\(([^)]{1,500})\\)`))?.[1] ?? null;
}
function decodePdfString(value: string | null): string | null {
if (!value) return null;
return value.replace(/\\([()\\])/g, "$1").trim() || null;
}

View File

@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { BookMetadata } from "./metadata.js";
@Injectable()
export class OpenLibraryService {
async enrich(metadata: BookMetadata): Promise<Partial<BookMetadata>> {
const query = metadata.isbn
? `isbn:${encodeURIComponent(metadata.isbn)}`
: `title:${encodeURIComponent(metadata.title)}`;
const response = await fetch(`https://openlibrary.org/search.json?q=${query}&limit=1`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
signal: AbortSignal.timeout(4000)
});
if (!response.ok) {
return {};
}
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
const doc = data.docs?.[0];
if (!doc) return {};
return {
author: metadata.author ?? firstArrayValue(doc.author_name),
language: metadata.language ?? firstArrayValue(doc.language),
publisher: metadata.publisher ?? firstArrayValue(doc.publisher),
publishedDate: metadata.publishedDate ?? (String(doc.first_publish_year ?? "") || null),
isbn: metadata.isbn ?? firstArrayValue(doc.isbn)
};
}
}
function firstArrayValue(value: unknown): string | null {
if (!Array.isArray(value) || !value.length) return null;
return String(value[0]);
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { OpenLibraryService } from "./open-library.service.js";
import { ScannerService } from "./scanner.service.js";
@Module({
imports: [DatabaseModule, JobsModule],
providers: [ScannerService, OpenLibraryService],
exports: [ScannerService]
})
export class ScannerModule {}

View File

@ -0,0 +1,91 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { readdirSync, statSync } from "node:fs";
import { extname, join } from "node:path";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { extractMetadata } from "./metadata.js";
import { OpenLibraryService } from "./open-library.service.js";
@Injectable()
export class ScannerService {
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
private readonly openLibrary: OpenLibraryService
) {}
enqueueLibraryScan(libraryId: number) {
const library = this.database.db.select().from(libraries).where(eq(libraries.id, libraryId)).get();
if (!library) {
throw new NotFoundException("Library not found");
}
const job = this.jobs.create("library-scan", `Scanning ${library.path}`);
setImmediate(() => {
void this.scanLibrary(job.id, library).catch((error) => this.jobs.markFailed(job.id, error));
});
return job;
}
private async scanLibrary(jobId: number, library: typeof libraries.$inferSelect): Promise<void> {
this.jobs.markRunning(jobId, `Scanning ${library.path}`);
let count = 0;
for (const filePath of walkBooks(library.path)) {
await this.ingestFile(library.id, filePath);
count += 1;
}
this.jobs.markSucceeded(jobId, `Scanned ${count} file(s)`);
}
private async ingestFile(libraryId: number, filePath: string): Promise<void> {
const stats = statSync(filePath);
let metadata = extractMetadata(filePath, this.database.config.storageDir);
if (this.database.config.openLibraryEnabled) {
try {
metadata = { ...metadata, ...(await this.openLibrary.enrich(metadata)) };
} catch {
// Remote enrichment is opportunistic; local ingestion must stay deterministic.
}
}
const now = this.database.now();
const format: "epub" | "pdf" = extname(filePath).toLowerCase() === ".epub" ? "epub" : "pdf";
const existing = this.database.db.select({ id: books.id }).from(books).where(eq(books.filePath, filePath)).get();
const values = {
libraryId,
title: metadata.title,
author: metadata.author,
description: metadata.description,
isbn: metadata.isbn,
language: metadata.language,
publisher: metadata.publisher,
publishedDate: metadata.publishedDate,
format,
filePath,
coverPath: metadata.coverPath,
fileSize: stats.size,
fileMtime: stats.mtime.toISOString(),
updatedAt: now
};
existing
? this.database.db.update(books).set(values).where(eq(books.id, existing.id)).returning().get()
: this.database.db.insert(books).values({ ...values, createdAt: now }).returning().get();
}
}
function* walkBooks(root: string): Generator<string> {
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) {
yield* walkBooks(path);
continue;
}
if (!entry.isFile()) continue;
const extension = extname(entry.name).toLowerCase();
if (extension === ".epub" || extension === ".pdf") {
yield path;
}
}
}

16
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": ["node"],
"paths": {
"@readabook/shared": ["../../packages/shared/src/index.ts"]
}
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*.ts"]
}