feat(opds): implement OPDS catalog with cover token auth

This commit is contained in:
neon
2026-02-08 22:57:09 -06:00
parent e232c44d88
commit d44d68cd12
28 changed files with 2452 additions and 8 deletions
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { Plus, Trash2, Copy, Check, Rss } from 'lucide-vue-next'
import { api } from '@/lib/api'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import type { OpdsUser, OpdsSortOrder } from '@projectx/types'
const { hasPermission } = usePermissions()
const canManageSettings = computed(() => hasPermission('manage_app_settings'))
const opdsEnabled = ref(true)
const opdsUsers = ref<OpdsUser[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const copied = ref(false)
const showCreateForm = ref(false)
const createUsername = ref('')
const createPassword = ref('')
const createSortOrder = ref<OpdsSortOrder>('recent')
const creating = ref(false)
const createError = ref<string | null>(null)
const opdsUrl = computed(() => `${window.location.origin}/api/v1/opds`)
const sortOrderOptions: { label: string; value: OpdsSortOrder }[] = [
{ label: 'Recently Added', value: 'recent' },
{ label: 'Title (A-Z)', value: 'title_asc' },
{ label: 'Title (Z-A)', value: 'title_desc' },
{ label: 'Author (A-Z)', value: 'author_asc' },
{ label: 'Author (Z-A)', value: 'author_desc' },
{ label: 'Series (A-Z)', value: 'series_asc' },
{ label: 'Series (Z-A)', value: 'series_desc' },
]
function sortOrderLabel(value: OpdsSortOrder): string {
return sortOrderOptions.find((o) => o.value === value)?.label ?? value
}
onMounted(async () => {
try {
const [settingsRes, usersRes] = await Promise.all([api('/api/app-settings'), api('/api/v1/opds-users')])
if (settingsRes.ok) {
const settings = await settingsRes.json()
const row = settings.find((s: { key: string; value: string }) => s.key === 'opds_enabled')
opdsEnabled.value = row?.value === 'true'
}
if (usersRes.ok) {
opdsUsers.value = await usersRes.json()
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
loading.value = false
}
})
async function toggleOpds() {
const newVal = !opdsEnabled.value
const res = await api('/api/app-settings/opds_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) opdsEnabled.value = newVal
}
async function copyUrl() {
await navigator.clipboard.writeText(opdsUrl.value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
async function createUser() {
createError.value = null
creating.value = true
try {
const res = await api('/api/v1/opds-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: createUsername.value, password: createPassword.value, sortOrder: createSortOrder.value }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
createError.value = data.message ?? 'Failed to create OPDS user'
return
}
const user = await res.json()
opdsUsers.value.push(user)
showCreateForm.value = false
createUsername.value = ''
createPassword.value = ''
createSortOrder.value = 'recent'
} finally {
creating.value = false
}
}
async function updateSortOrder(user: OpdsUser, sortOrder: OpdsSortOrder) {
const res = await api(`/api/v1/opds-users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sortOrder }),
})
if (res.ok) {
const updated = await res.json()
const idx = opdsUsers.value.findIndex((u) => u.id === user.id)
if (idx >= 0) opdsUsers.value[idx] = updated
}
}
function cancelCreate() {
showCreateForm.value = false
createError.value = null
}
async function deleteUser(user: OpdsUser) {
if (!confirm(`Delete OPDS user "${user.username}"? This cannot be undone.`)) return
const res = await api(`/api/v1/opds-users/${user.id}`, { method: 'DELETE' })
if (res.ok) {
opdsUsers.value = opdsUsers.value.filter((u) => u.id !== user.id)
}
}
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">OPDS</h2>
<p class="mt-1 text-sm text-muted-foreground">
Connect OPDS-compatible reading apps like KOReader, Moon+ Reader, or Thorium Reader to your library.
</p>
</div>
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
<div v-else-if="error" class="text-sm text-destructive">{{ error }}</div>
<template v-else>
<!-- Server Toggle -->
<div v-if="canManageSettings" class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Server</p>
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">OPDS Catalog Server</p>
<p class="text-xs text-muted-foreground mt-0.5">Allow OPDS clients to browse and download books</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="opdsEnabled ? 'bg-primary' : 'bg-muted'"
@click="toggleOpds()"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="opdsEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<!-- Endpoint URL -->
<div v-if="opdsEnabled" class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Endpoint</p>
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-5 py-4 bg-card">
<Rss :size="14" class="text-muted-foreground shrink-0" />
<input :value="opdsUrl" readonly class="flex-1 text-sm bg-transparent text-foreground outline-none select-all min-w-0" />
<button
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
@click="copyUrl()"
>
<component :is="copied ? Check : Copy" :size="12" />
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
</div>
</div>
<!-- OPDS Users -->
<div v-if="opdsEnabled" class="mb-6">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">OPDS Accounts</p>
<button
v-if="!showCreateForm"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="showCreateForm = true"
>
<Plus :size="12" />
Add
</button>
</div>
<!-- Create form -->
<div v-if="showCreateForm" class="border border-border rounded-lg p-5 bg-card mb-4 space-y-4">
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">Username</label>
<input
v-model="createUsername"
type="text"
placeholder="e.g. koreader"
class="w-full h-9 px-3 text-sm border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">Password</label>
<input
v-model="createPassword"
type="password"
placeholder="Min 8 characters"
class="w-full h-9 px-3 text-sm border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">Default Sort</label>
<select
v-model="createSortOrder"
class="w-full h-9 px-3 text-sm border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option v-for="opt in sortOrderOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div v-if="createError" class="text-xs text-destructive">{{ createError }}</div>
<div class="flex items-center gap-2 pt-1">
<button
class="px-4 py-2 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="creating || !createUsername || !createPassword"
@click="createUser()"
>
{{ creating ? 'Creating...' : 'Create' }}
</button>
<button
class="px-4 py-2 text-xs font-medium rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
@click="cancelCreate()"
>
Cancel
</button>
</div>
</div>
<!-- Users list -->
<div v-if="opdsUsers.length === 0 && !showCreateForm" class="border border-border rounded-lg px-5 py-8 bg-card text-center">
<p class="text-sm text-muted-foreground">No OPDS accounts yet. Create one to start using OPDS clients.</p>
</div>
<div v-else-if="opdsUsers.length > 0" class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="user in opdsUsers" :key="user.id" class="flex items-center gap-3 px-5 py-3.5 bg-card">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ user.username }}</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ sortOrderLabel(user.sortOrder) }}</p>
</div>
<select
:value="user.sortOrder"
class="text-xs border border-border rounded-md px-2 py-1 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
@change="updateSortOrder(user, ($event.target as HTMLSelectElement).value as OpdsSortOrder)"
>
<option v-for="opt in sortOrderOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
@click="deleteUser(user)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -15,6 +15,7 @@ import {
ShieldCheck,
KeyRound,
LogIn,
Rss,
} from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -30,21 +31,25 @@ import UsersPage from '@/features/admin/UsersPage.vue'
import RolesPage from '@/features/admin/RolesPage.vue'
import PermissionsPage from '@/features/admin/PermissionsPage.vue'
import OidcSettings from './OidcSettings.vue'
import OpdsSettings from './OpdsSettings.vue'
const { isOpen, close } = useSettingsDrawer()
const { isSuperuser, hasPermission, userPermissions } = usePermissions()
const themeStore = useThemeStore()
const bgClass = computed(() => BACKGROUND_OPTIONS.find((o) => o.id === themeStore.background)?.cssClass ?? '')
type SectionId = 'libraries' | 'appearance' | 'reader' | 'ebook' | 'pdf' | 'comics' | 'about' | 'users' | 'roles' | 'permissions' | 'oidc'
type SectionId = 'libraries' | 'appearance' | 'opds' | 'reader' | 'ebook' | 'pdf' | 'comics' | 'about' | 'users' | 'roles' | 'permissions' | 'oidc'
const navGroups = computed(() => {
const perms = userPermissions.value
const su = isSuperuser.value
const groups = [
{
label: 'General',
items: [
{ id: 'libraries' as SectionId, label: 'Libraries', icon: Library, component: LibrariesSettings },
{ id: 'appearance' as SectionId, label: 'Appearance', icon: Paintbrush, component: AppearanceSettings },
...(su || perms.includes('opds_access') ? [{ id: 'opds' as SectionId, label: 'OPDS', icon: Rss, component: OpdsSettings }] : []),
],
},
{
@@ -57,9 +62,6 @@ const navGroups = computed(() => {
],
},
]
// Explicitly reference userPermissions.value so Vue tracks it as a dependency
const perms = userPermissions.value
const su = isSuperuser.value
const adminItems: { id: SectionId; label: string; icon: unknown; component: unknown }[] = []
if (su || perms.includes('manage_users')) {
adminItems.push({ id: 'users', label: 'Users', icon: Users, component: UsersPage })
+1
View File
@@ -5,3 +5,4 @@ export * from "./library";
export * from "./query";
export * from "./reader-settings";
export * from "./scanner";
export * from "./opds";
+19
View File
@@ -0,0 +1,19 @@
export type OpdsSortOrder = "recent" | "title_asc" | "title_desc" | "author_asc" | "author_desc" | "series_asc" | "series_desc";
export interface OpdsUser {
id: number;
userId: number;
username: string;
sortOrder: OpdsSortOrder;
createdAt: string;
}
export interface CreateOpdsUserRequest {
username: string;
password: string;
sortOrder?: OpdsSortOrder;
}
export interface UpdateOpdsUserRequest {
sortOrder: OpdsSortOrder;
}
+2
View File
@@ -19,6 +19,7 @@ import { BookModule } from './modules/book/book.module';
import { KoboModule } from './modules/kobo/kobo.module';
import { LibraryModule } from './modules/library/library.module';
import { LensModule } from './modules/lens/lens.module';
import { OpdsModule } from './modules/opds/opds.module';
import { PathModule } from './modules/path/path.module';
import { MetadataModule } from './modules/metadata/metadata.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
@@ -49,6 +50,7 @@ import { UserModule } from './modules/user/user.module';
ScannerModule,
MetadataModule,
KoboModule,
OpdsModule,
BookmarkModule,
AnnotationModule,
CbzModule,
@@ -1,4 +1,5 @@
CREATE TYPE "public"."library_access_level" AS ENUM('viewer', 'editor', 'owner');--> statement-breakpoint
CREATE TYPE "public"."opds_sort_order" AS ENUM('recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc');--> statement-breakpoint
CREATE TABLE "app_settings" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar(100) NOT NULL,
@@ -291,6 +292,17 @@ CREATE TABLE "oidc_sessions" (
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "opds_users" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"username" varchar(100) NOT NULL,
"password_hash" varchar(255) NOT NULL,
"sort_order" "opds_sort_order" DEFAULT 'recent' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "opds_users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -325,6 +337,7 @@ ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_boo
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oidc_group_mappings" ADD CONSTRAINT "oidc_group_mappings_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "oidc_sessions" ADD CONSTRAINT "oidc_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "opds_users" ADD CONSTRAINT "opds_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
@@ -1,5 +1,5 @@
{
"id": "4ca552ff-02e0-40e0-bb1f-2c1e53060015",
"id": "a7bff586-08d5-43a7-a274-bfdc9b116bf7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -2345,6 +2345,87 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.opds_users": {
"name": "opds_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "opds_sort_order",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'recent'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"opds_users_user_id_users_id_fk": {
"name": "opds_users_user_id_users_id_fk",
"tableFrom": "opds_users",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"opds_users_username_unique": {
"name": "opds_users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
@@ -2356,6 +2437,19 @@
"editor",
"owner"
]
},
"public.opds_sort_order": {
"name": "opds_sort_order",
"schema": "public",
"values": [
"recent",
"title_asc",
"title_desc",
"author_asc",
"author_desc",
"series_asc",
"series_desc"
]
}
},
"schemas": {},
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1770528090812,
"tag": "0000_add_rating_and_book_card_fields",
"when": 1770659428834,
"tag": "0000_baseline",
"breakpoints": true
}
]
+1
View File
@@ -7,3 +7,4 @@ export * from './metadata';
export * from './scanner';
export * from './reader';
export * from './oidc';
export * from './opds';
+28
View File
@@ -0,0 +1,28 @@
import { integer, pgEnum, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const opdsSortOrderEnum = pgEnum('opds_sort_order', [
'recent',
'title_asc',
'title_desc',
'author_asc',
'author_desc',
'series_asc',
'series_desc',
]);
export const opdsUsers = pgTable('opds_users', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
username: varchar('username', { length: 100 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
sortOrder: opdsSortOrderEnum('sort_order').notNull().default('recent'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
});
@@ -0,0 +1,148 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import { OpdsAuthGuard } from '../opds-auth.guard';
import type { OpdsRequestUser } from '../opds-auth.guard';
import type { OpdsUserService } from '../opds-user.service';
import type { UserService } from '../../user/user.service';
import type { PermissionService } from '../../../common/services/permission.service';
const TEST_SECRET = 'test-jwt-secret';
function mockContext(authHeader?: string, tokenParam?: string) {
const query: Record<string, string> = {};
if (tokenParam) query.t = tokenParam;
const request: Record<string, unknown> = { headers: { authorization: authHeader }, query };
const reply = { header: jest.fn() };
return {
request,
reply,
context: {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext,
};
}
const OPDS_USER = {
id: 10,
userId: 1,
username: 'reader',
passwordHash: '$2a',
sortOrder: 'recent' as const,
createdAt: new Date(),
updatedAt: new Date(),
};
const PARENT_USER = { id: 1, username: 'admin', name: 'Admin', active: true };
const FULL_USER = {
id: 1,
username: 'admin',
name: 'Admin',
email: null,
active: true,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
roles: [{ id: 1, name: 'User', description: null, isSuperuser: false, isSystem: true, permissions: [{ id: 99, name: 'opds_access' }] }],
};
function makeGuard(overrides: { validateResult?: unknown; fullUser?: unknown; userHas?: boolean } = {}) {
const opdsUserService = {
validateCredentials: jest
.fn()
.mockResolvedValue(overrides.validateResult !== undefined ? overrides.validateResult : { opdsUser: OPDS_USER, parentUser: PARENT_USER }),
};
const userService = {
findByIdWithRolesAndPermissions: jest.fn().mockResolvedValue(overrides.fullUser !== undefined ? overrides.fullUser : FULL_USER),
};
const permissionService = {
userHas: jest.fn().mockReturnValue(overrides.userHas !== undefined ? overrides.userHas : true),
};
const configService = { get: jest.fn().mockReturnValue(TEST_SECRET) };
return new OpdsAuthGuard(
opdsUserService as unknown as OpdsUserService,
userService as unknown as UserService,
permissionService as unknown as PermissionService,
configService as unknown as ConfigService,
);
}
function basicHeader(user: string, pass: string) {
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
}
describe('OpdsAuthGuard', () => {
it('returns 401 with WWW-Authenticate when no Authorization header', async () => {
const guard = makeGuard();
const { context, reply } = mockContext(undefined);
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
expect(reply.header).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm="projectx OPDS"');
});
it('returns 401 when Authorization is not Basic', async () => {
const guard = makeGuard();
const { context, reply } = mockContext('Bearer xyz');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
expect(reply.header).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm="projectx OPDS"');
});
it('returns 401 when decoded credentials have no colon', async () => {
const guard = makeGuard();
const header = `Basic ${Buffer.from('nocolon').toString('base64')}`;
const { context, reply } = mockContext(header);
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
expect(reply.header).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm="projectx OPDS"');
});
it('returns 401 when credentials are invalid', async () => {
const guard = makeGuard({ validateResult: null });
const { context, reply } = mockContext(basicHeader('bad', 'creds'));
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
expect(reply.header).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm="projectx OPDS"');
});
it('returns 401 when parent user is inactive', async () => {
const guard = makeGuard({ validateResult: { opdsUser: OPDS_USER, parentUser: { ...PARENT_USER, active: false } } });
const { context } = mockContext(basicHeader('reader', 'pass'));
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('returns 401 when parent user not found via findByIdWithRolesAndPermissions', async () => {
const guard = makeGuard({ fullUser: null });
const { context } = mockContext(basicHeader('reader', 'pass'));
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('returns 403 when parent user lacks opds_access permission', async () => {
const guard = makeGuard({ userHas: false });
const { context } = mockContext(basicHeader('reader', 'pass'));
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('attaches opdsUser to request on success', async () => {
const guard = makeGuard();
const { context, request } = mockContext(basicHeader('reader', 'pass'));
const result = await guard.canActivate(context);
expect(result).toBe(true);
const opdsUser = request.opdsUser as OpdsRequestUser;
expect(opdsUser.opdsUserId).toBe(10);
expect(opdsUser.userId).toBe(1);
expect(opdsUser.username).toBe('reader');
expect(opdsUser.sortOrder).toBe('recent');
expect(opdsUser.isSuperuser).toBe(false);
expect(typeof opdsUser.coverToken).toBe('string');
expect(opdsUser.coverToken).toMatch(/^1\./);
});
it('handles password containing colons', async () => {
const guard = makeGuard();
const { context } = mockContext(basicHeader('reader', 'pa:ss:word'));
await guard.canActivate(context);
const svc = (guard as unknown as { opdsUserService: { validateCredentials: jest.Mock } }).opdsUserService;
expect(svc.validateCredentials).toHaveBeenCalledWith('reader', 'pa:ss:word');
});
});
@@ -0,0 +1,26 @@
import { ForbiddenException } from '@nestjs/common';
import { OpdsEnabledGuard } from '../opds-enabled.guard';
import type { AppSettingsService } from '../../app-settings/app-settings.service';
function makeGuard(settings: { key: string; value: string }[]) {
const appSettingsService = { findAll: jest.fn().mockResolvedValue(settings) } as unknown as AppSettingsService;
return new OpdsEnabledGuard(appSettingsService);
}
describe('OpdsEnabledGuard', () => {
it('passes when opds_enabled is true', async () => {
const guard = makeGuard([{ key: 'opds_enabled', value: 'true' }]);
await expect(guard.canActivate()).resolves.toBe(true);
});
it('throws ForbiddenException when opds_enabled is false', async () => {
const guard = makeGuard([{ key: 'opds_enabled', value: 'false' }]);
await expect(guard.canActivate()).rejects.toThrow(ForbiddenException);
});
it('throws ForbiddenException when opds_enabled setting is missing', async () => {
const guard = makeGuard([]);
await expect(guard.canActivate()).rejects.toThrow(ForbiddenException);
});
});
@@ -0,0 +1,188 @@
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DB } from '../../../db';
import { OpdsUserService } from '../opds-user.service';
const mockReturning = jest.fn();
const mockWhere = jest.fn().mockReturnValue({ returning: mockReturning });
const mockSet = jest.fn().mockReturnValue({ where: mockWhere });
const mockValues = jest.fn();
function makeMockDb() {
return {
select: jest.fn().mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockResolvedValue([]),
}),
}),
}),
insert: jest.fn().mockReturnValue({
values: mockValues,
}),
update: jest.fn().mockReturnValue({
set: mockSet,
}),
delete: jest.fn().mockReturnValue({
where: jest.fn().mockResolvedValue(undefined),
}),
query: {
opdsUsers: { findFirst: jest.fn() },
users: { findFirst: jest.fn() },
},
};
}
describe('OpdsUserService', () => {
let service: OpdsUserService;
let db: ReturnType<typeof makeMockDb>;
beforeEach(async () => {
db = makeMockDb();
const module = await Test.createTestingModule({
providers: [OpdsUserService, { provide: DB, useValue: db }],
}).compile();
service = module.get(OpdsUserService);
});
describe('findAllForUser', () => {
it('returns OPDS users for the given userId', async () => {
const expected = [{ id: 1, userId: 5, username: 'reader', sortOrder: 'recent', createdAt: new Date() }];
db.select.mockReturnValue({
from: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockResolvedValue(expected),
}),
}),
});
const result = await service.findAllForUser(5);
expect(result).toEqual(expected);
});
});
describe('create', () => {
it('creates an OPDS user with hashed password', async () => {
const created = { id: 1, userId: 5, username: 'newuser', sortOrder: 'recent', createdAt: new Date() };
mockValues.mockReturnValue({
returning: jest.fn().mockResolvedValue([created]),
});
const result = await service.create(5, { username: 'newuser', password: 'password123' });
expect(result).toEqual(created);
});
it('throws ConflictException on duplicate username', async () => {
const pgError: Error & { code?: string } = new Error('unique violation');
pgError.code = '23505';
mockValues.mockReturnValue({
returning: jest.fn().mockRejectedValue(pgError),
});
await expect(service.create(5, { username: 'duplicate', password: 'password123' })).rejects.toThrow(ConflictException);
});
it('rethrows non-unique-violation errors', async () => {
const genericError = new Error('connection lost');
mockValues.mockReturnValue({
returning: jest.fn().mockRejectedValue(genericError),
});
await expect(service.create(5, { username: 'user', password: 'password123' })).rejects.toThrow('connection lost');
});
});
describe('update', () => {
it('updates sort order for owned OPDS user', async () => {
const existing = { id: 10, userId: 5 };
db.query.opdsUsers.findFirst.mockResolvedValue(existing);
const updated = { id: 10, userId: 5, username: 'reader', sortOrder: 'title_asc', createdAt: new Date() };
mockReturning.mockResolvedValue([updated]);
const result = await service.update(5, 10, { sortOrder: 'title_asc' });
expect(result).toEqual(updated);
});
it('throws ForbiddenException when user does not own the OPDS user', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue(undefined);
await expect(service.update(5, 10, { sortOrder: 'title_asc' })).rejects.toThrow(ForbiddenException);
});
it('throws NotFoundException if update returns no row', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue({ id: 10, userId: 5 });
mockReturning.mockResolvedValue([]);
await expect(service.update(5, 10, { sortOrder: 'title_asc' })).rejects.toThrow(NotFoundException);
});
});
describe('delete', () => {
it('deletes an owned OPDS user', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue({ id: 10, userId: 5 });
await expect(service.delete(5, 10)).resolves.toBeUndefined();
});
it('throws ForbiddenException when user does not own the OPDS user', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue(undefined);
await expect(service.delete(5, 10)).rejects.toThrow(ForbiddenException);
});
});
describe('validateCredentials', () => {
it('returns null when OPDS user not found', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue(undefined);
const result = await service.validateCredentials('nobody', 'pass');
expect(result).toBeNull();
});
it('returns null when password does not match', async () => {
db.query.opdsUsers.findFirst.mockResolvedValue({
id: 1,
userId: 5,
username: 'reader',
passwordHash: '$2a$12$invalidhashthatshouldnotmatch000000000000000000',
});
const result = await service.validateCredentials('reader', 'wrongpass');
expect(result).toBeNull();
});
it('returns null when parent user not found', async () => {
const { hash } = await import('bcryptjs');
const passwordHash = await hash('correctpass', 4);
db.query.opdsUsers.findFirst.mockResolvedValue({
id: 1,
userId: 999,
username: 'reader',
passwordHash,
});
db.query.users.findFirst.mockResolvedValue(undefined);
const result = await service.validateCredentials('reader', 'correctpass');
expect(result).toBeNull();
});
it('returns opdsUser and parentUser on valid credentials', async () => {
const { hash } = await import('bcryptjs');
const passwordHash = await hash('correctpass', 4);
const opdsUser = { id: 1, userId: 5, username: 'reader', passwordHash, sortOrder: 'recent' };
const parentUser = { id: 5, username: 'admin', active: true };
db.query.opdsUsers.findFirst.mockResolvedValue(opdsUser);
db.query.users.findFirst.mockResolvedValue(parentUser);
const result = await service.validateCredentials('reader', 'correctpass');
expect(result).not.toBeNull();
expect(result!.opdsUser).toEqual(opdsUser);
expect(result!.parentUser).toEqual(parentUser);
});
});
});
@@ -0,0 +1,63 @@
import { esc, xmlEl, xmlLink, fileMimeType } from '../opds-xml.helpers';
describe('esc', () => {
it('escapes all five XML special characters', () => {
expect(esc('a & b < c > d " e \' f')).toBe('a &amp; b &lt; c &gt; d &quot; e &apos; f');
});
it('returns empty string for null/undefined', () => {
expect(esc(null)).toBe('');
expect(esc(undefined)).toBe('');
});
it('returns empty string for empty string', () => {
expect(esc('')).toBe('');
});
it('leaves safe strings unchanged', () => {
expect(esc('hello world')).toBe('hello world');
});
});
describe('xmlEl', () => {
it('wraps text in a tag with escaping', () => {
expect(xmlEl('title', 'Rock & Roll')).toBe('<title>Rock &amp; Roll</title>');
});
it('handles null text', () => {
expect(xmlEl('title', null)).toBe('<title></title>');
});
});
describe('xmlLink', () => {
it('produces a self-closing link element', () => {
const result = xmlLink('self', '/feed', 'application/atom+xml');
expect(result).toBe('<link rel="self" href="/feed" type="application/atom+xml"/>');
});
it('includes optional title with escaping', () => {
const result = xmlLink('subsection', '/lib', 'text/html', 'My "Library"');
expect(result).toContain('title="My &quot;Library&quot;"');
});
it('escapes href', () => {
const result = xmlLink('self', '/feed?a=1&b=2', 'text/xml');
expect(result).toContain('href="/feed?a=1&amp;b=2"');
});
});
describe('fileMimeType', () => {
it.each([
['epub', 'application/epub+zip'],
['pdf', 'application/pdf'],
['mobi', 'application/x-mobipocket-ebook'],
['azw3', 'application/vnd.amazon.ebook'],
['fb2', 'application/x-fictionbook+xml'],
['cbz', 'application/vnd.comicbook+zip'],
['cbr', 'application/vnd.comicbook-rar'],
['EPUB', 'application/epub+zip'],
['unknown', 'application/octet-stream'],
])('maps %s to %s', (format, expected) => {
expect(fileMimeType(format)).toBe(expected);
});
});
@@ -0,0 +1,356 @@
import { OpdsService } from '../opds.service';
import type { OpdsBookEntry } from '../opds-book.service';
function makeService() {
return new OpdsService();
}
const BASE = '/api/v1/opds';
function sampleBook(overrides?: Partial<OpdsBookEntry>): OpdsBookEntry {
return {
id: 1,
title: 'Mistborn: The Final Empire',
folderPath: '/books/mistborn',
addedAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-02'),
description: 'A fantasy novel by Brandon Sanderson',
seriesName: 'Mistborn',
seriesIndex: 1,
language: 'en',
publisher: 'Tor Books',
isbn13: '9780765311788',
authors: ['Brandon Sanderson'],
files: [{ id: 10, format: 'epub' }],
...overrides,
};
}
describe('OpdsService', () => {
describe('generateRootNavigation', () => {
it('produces valid OPDS navigation XML', () => {
const service = makeService();
const xml = service.generateRootNavigation();
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain('<feed xmlns="http://www.w3.org/2005/Atom"');
expect(xml).toContain('<title>projectx OPDS Catalog</title>');
expect(xml).toContain('<id>urn:projectx:root</id>');
});
it('contains all expected navigation entries', () => {
const service = makeService();
const xml = service.generateRootNavigation();
expect(xml).toContain('All Books');
expect(xml).toContain('Recent Books');
expect(xml).toContain('Random Books');
expect(xml).toContain('Libraries');
expect(xml).toContain('Collections');
expect(xml).toContain('Lenses');
expect(xml).toContain('Authors');
expect(xml).toContain('Series');
});
it('includes search link', () => {
const service = makeService();
const xml = service.generateRootNavigation();
expect(xml).toContain(`href="${BASE}/search.opds"`);
});
it('uses relative paths for all links', () => {
const service = makeService();
const xml = service.generateRootNavigation();
expect(xml).not.toContain('http://localhost');
expect(xml).toContain(`href="${BASE}"`);
expect(xml).toContain(`href="${BASE}/catalog"`);
});
});
describe('generateLibrariesNavigation', () => {
it('generates entries for each library', () => {
const service = makeService();
const xml = service.generateLibrariesNavigation([
{ id: 1, name: 'Fiction', bookCount: 42 },
{ id: 2, name: 'Non-Fiction', bookCount: 13 },
]);
expect(xml).toContain('<title>Fiction</title>');
expect(xml).toContain('42 books');
expect(xml).toContain(`${BASE}/catalog?libraryId=1`);
expect(xml).toContain('<title>Non-Fiction</title>');
});
it('handles empty libraries list', () => {
const service = makeService();
const xml = service.generateLibrariesNavigation([]);
expect(xml).toContain('<title>Libraries</title>');
expect(xml).not.toContain('<entry>');
});
});
describe('generateCollectionsNavigation', () => {
it('generates entries for each collection', () => {
const service = makeService();
const xml = service.generateCollectionsNavigation([{ id: 5, name: 'Favorites', bookCount: 7 }]);
expect(xml).toContain('<title>Favorites</title>');
expect(xml).toContain(`${BASE}/catalog?collectionId=5`);
});
});
describe('generateAuthorsNavigation', () => {
it('URL-encodes author names', () => {
const service = makeService();
const xml = service.generateAuthorsNavigation([{ name: 'J.R.R. Tolkien', bookCount: 12 }]);
expect(xml).toContain('<title>J.R.R. Tolkien</title>');
expect(xml).toContain(encodeURIComponent('J.R.R. Tolkien'));
});
});
describe('generateSeriesNavigation', () => {
it('URL-encodes series names', () => {
const service = makeService();
const xml = service.generateSeriesNavigation([{ name: 'The Lord of the Rings', bookCount: 3 }]);
expect(xml).toContain(encodeURIComponent('The Lord of the Rings'));
});
});
describe('generateAcquisitionFeed', () => {
it('includes book entry with title, author, and acquisition link', () => {
const service = makeService();
const book = sampleBook();
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('<title>Mistborn: The Final Empire</title>');
expect(xml).toContain('<name>Brandon Sanderson</name>');
expect(xml).toContain('application/epub+zip');
expect(xml).toContain('http://opds-spec.org/acquisition');
});
it('includes cover and thumbnail links with relative paths', () => {
const service = makeService();
const book = sampleBook();
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain(`${BASE}/1/cover`);
expect(xml).toContain(`${BASE}/1/thumbnail`);
expect(xml).toContain('http://opds-spec.org/image"');
expect(xml).toContain('http://opds-spec.org/image/thumbnail"');
expect(xml).not.toMatch(/href="http:\/\/localhost/);
});
it('includes series link when seriesName is set', () => {
const service = makeService();
const book = sampleBook();
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('Mistborn #1');
});
it('omits series link when seriesName is null', () => {
const service = makeService();
const book = sampleBook({ seriesName: null, seriesIndex: null });
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).not.toContain('http://opds-spec.org/sort/series');
});
it('includes Dublin Core metadata', () => {
const service = makeService();
const book = sampleBook();
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('<dc:language>en</dc:language>');
expect(xml).toContain('<dc:publisher>Tor Books</dc:publisher>');
expect(xml).toContain('urn:isbn:9780765311788');
});
it('includes totalResults element', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed('Catalog', 'urn:projectx:catalog', [], 42, 1, 50, `${BASE}/catalog?page=1&size=50`, 'test-token');
expect(xml).toContain('<opensearch:totalResults>42</opensearch:totalResults>');
});
it('adds pagination links for multi-page results', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed('Catalog', 'urn:projectx:catalog', [], 100, 2, 10, `${BASE}/catalog?size=10&page=2`, 'test-token');
expect(xml).toContain('rel="previous"');
expect(xml).toContain('rel="next"');
expect(xml).toContain('rel="first"');
expect(xml).toContain('rel="last"');
});
it('generates correct pagination URLs', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed('Catalog', 'urn:projectx:catalog', [], 100, 2, 10, `${BASE}/catalog?size=10&page=2`, 'test-token');
expect(xml).toContain(`${BASE}/catalog?size=10&amp;page=1`);
expect(xml).toContain(`${BASE}/catalog?size=10&amp;page=3`);
expect(xml).toContain(`${BASE}/catalog?size=10&amp;page=10`);
});
it('generates correct pagination URLs with filters', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[],
100,
2,
10,
`${BASE}/catalog?libraryId=5&page=2&size=10`,
'test-token',
);
expect(xml).toContain('libraryId=5');
expect(xml).toContain('page=3');
expect(xml).not.toMatch(/page=2&amp;page=/);
});
it('omits previous/first links on page 1', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed('Catalog', 'urn:projectx:catalog', [], 100, 1, 10, `${BASE}/catalog?size=10&page=1`, 'test-token');
expect(xml).not.toContain('rel="previous"');
expect(xml).not.toContain('rel="first"');
expect(xml).toContain('rel="next"');
});
it('omits next/last links on last page', () => {
const service = makeService();
const xml = service.generateAcquisitionFeed('Catalog', 'urn:projectx:catalog', [], 20, 2, 10, `${BASE}/catalog?size=10&page=2`, 'test-token');
expect(xml).not.toContain('rel="next"');
expect(xml).not.toContain('rel="last"');
expect(xml).toContain('rel="previous"');
});
it('handles multiple file formats per book', () => {
const service = makeService();
const book = sampleBook({
files: [
{ id: 10, format: 'epub' },
{ id: 11, format: 'pdf' },
],
});
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('application/epub+zip');
expect(xml).toContain('application/pdf');
expect(xml).toContain('fileId=10');
expect(xml).toContain('fileId=11');
});
});
describe('generateOpenSearchDescription', () => {
it('produces valid OpenSearch XML with search template', () => {
const service = makeService();
const xml = service.generateOpenSearchDescription();
expect(xml).toContain('<OpenSearchDescription');
expect(xml).toContain('<ShortName>projectx OPDS</ShortName>');
expect(xml).toContain('{searchTerms}');
expect(xml).toContain(`${BASE}/catalog?q={searchTerms}`);
});
});
describe('XML escaping', () => {
it('escapes special characters in titles', () => {
const service = makeService();
const book = sampleBook({ title: 'Rock & Roll <Vol. 1> "Greatest"' });
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('&amp;');
expect(xml).toContain('&lt;');
expect(xml).toContain('&gt;');
expect(xml).toContain('&quot;');
expect(xml).not.toContain('<Vol. 1>');
});
it('escapes special characters in author names', () => {
const service = makeService();
const book = sampleBook({ authors: ["O'Brien & Sons"] });
const xml = service.generateAcquisitionFeed(
'Catalog',
'urn:projectx:catalog',
[book],
1,
1,
50,
`${BASE}/catalog?page=1&size=50`,
'test-token',
);
expect(xml).toContain('&amp;');
expect(xml).toContain('&apos;');
});
});
});
@@ -0,0 +1,18 @@
import { IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
const SORT_ORDER_VALUES = ['recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc'] as const;
export class CreateOpdsUserDto {
@IsString()
@MinLength(3)
@MaxLength(100)
username: string;
@IsString()
@MinLength(8)
password: string;
@IsOptional()
@IsEnum(SORT_ORDER_VALUES)
sortOrder?: (typeof SORT_ORDER_VALUES)[number];
}
@@ -0,0 +1,8 @@
import { IsEnum } from 'class-validator';
const SORT_ORDER_VALUES = ['recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc'] as const;
export class UpdateOpdsUserDto {
@IsEnum(SORT_ORDER_VALUES)
sortOrder: (typeof SORT_ORDER_VALUES)[number];
}
+118
View File
@@ -0,0 +1,118 @@
import { createHmac } from 'crypto';
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { PermissionService } from '../../common/services/permission.service';
import { OpdsUserService } from './opds-user.service';
import { UserService } from '../user/user.service';
export interface OpdsRequestUser {
opdsUserId: number;
userId: number;
username: string;
sortOrder: 'recent' | 'title_asc' | 'title_desc' | 'author_asc' | 'author_desc' | 'series_asc' | 'series_desc';
isSuperuser: boolean;
coverToken: string;
}
export function createCoverToken(userId: number, secret: string): string {
const sig = createHmac('sha256', secret).update(String(userId)).digest('hex').slice(0, 32);
return `${userId}.${sig}`;
}
function parseCoverToken(token: string, secret: string): number | null {
const dot = token.indexOf('.');
if (dot === -1) return null;
const userId = parseInt(token.slice(0, dot), 10);
if (isNaN(userId) || userId <= 0) return null;
const expected = createCoverToken(userId, secret);
if (token !== expected) return null;
return userId;
}
@Injectable()
export class OpdsAuthGuard implements CanActivate {
constructor(
private readonly opdsUserService: OpdsUserService,
private readonly userService: UserService,
private readonly permissionService: PermissionService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const reply = context.switchToHttp().getResponse<FastifyReply>();
const tokenParam = (request.query as Record<string, string | undefined>).t;
if (tokenParam) {
const secret = this.config.get<string>('auth.jwtSecret')!;
const userId = parseCoverToken(tokenParam, secret);
if (!userId) throw new UnauthorizedException('Invalid token');
const fullUser = await this.userService.findByIdWithRolesAndPermissions(userId);
if (!fullUser || !fullUser.active) throw new UnauthorizedException('Account not found or disabled');
if (!this.permissionService.userHas(fullUser, 'opds_access')) throw new ForbiddenException('OPDS access revoked');
(request as unknown as Record<string, unknown>).opdsUser = {
opdsUserId: 0,
userId: fullUser.id,
username: fullUser.username,
sortOrder: 'recent',
isSuperuser: fullUser.roles.some((r) => r.isSuperuser),
coverToken: tokenParam,
} satisfies OpdsRequestUser;
return true;
}
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Basic ')) {
reply.header('WWW-Authenticate', 'Basic realm="projectx OPDS"');
throw new UnauthorizedException('Basic authentication required');
}
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const colonIndex = decoded.indexOf(':');
if (colonIndex === -1) {
reply.header('WWW-Authenticate', 'Basic realm="projectx OPDS"');
throw new UnauthorizedException('Invalid credentials');
}
const username = decoded.slice(0, colonIndex);
const password = decoded.slice(colonIndex + 1);
const result = await this.opdsUserService.validateCredentials(username, password);
if (!result) {
reply.header('WWW-Authenticate', 'Basic realm="projectx OPDS"');
throw new UnauthorizedException('Invalid credentials');
}
if (!result.parentUser.active) {
throw new UnauthorizedException('Account is disabled');
}
const fullUser = await this.userService.findByIdWithRolesAndPermissions(result.parentUser.id);
if (!fullUser) {
throw new UnauthorizedException('Account not found');
}
if (!this.permissionService.userHas(fullUser, 'opds_access')) {
throw new ForbiddenException('OPDS access revoked');
}
const secret = this.config.get<string>('auth.jwtSecret')!;
(request as unknown as Record<string, unknown>).opdsUser = {
opdsUserId: result.opdsUser.id,
userId: result.parentUser.id,
username: result.opdsUser.username,
sortOrder: result.opdsUser.sortOrder,
isSuperuser: fullUser.roles.some((r) => r.isSuperuser),
coverToken: createCoverToken(result.parentUser.id, secret),
} satisfies OpdsRequestUser;
return true;
}
}
@@ -0,0 +1,393 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { SQL, and, count, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
authors,
bookAuthors,
bookFiles,
bookMetadata,
books,
collections,
collectionBooks,
lenses,
libraries,
userLibraryAccess,
} from '../../db/schema';
import { BookQueryBuilder } from '../book/book-query-builder.service';
import type { GroupRule } from '@projectx/types';
type Db = NodePgDatabase<typeof schema>;
type OpdsSortOrder = 'recent' | 'title_asc' | 'title_desc' | 'author_asc' | 'author_desc' | 'series_asc' | 'series_desc';
const OPDS_SORT_MAP: Record<OpdsSortOrder, SQL[]> = {
recent: [sql`${books.addedAt} DESC`],
title_asc: [sql`${bookMetadata.title} ASC NULLS LAST`],
title_desc: [sql`${bookMetadata.title} DESC NULLS LAST`],
author_asc: [sql`min(${authors.sortName}) ASC NULLS LAST`, sql`${bookMetadata.title} ASC NULLS LAST`],
author_desc: [sql`min(${authors.sortName}) DESC NULLS LAST`, sql`${bookMetadata.title} ASC NULLS LAST`],
series_asc: [sql`${bookMetadata.seriesName} ASC NULLS LAST`, sql`${bookMetadata.seriesIndex} ASC NULLS LAST`],
series_desc: [sql`${bookMetadata.seriesName} DESC NULLS LAST`, sql`${bookMetadata.seriesIndex} DESC NULLS LAST`],
};
export interface OpdsBookEntry {
id: number;
title: string;
folderPath: string;
addedAt: Date;
updatedAt: Date;
description: string | null;
seriesName: string | null;
seriesIndex: number | null;
language: string | null;
publisher: string | null;
isbn13: string | null;
authors: string[];
files: { id: number; format: string }[];
}
@Injectable()
export class OpdsBookService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly queryBuilder: BookQueryBuilder,
) {}
async getAccessibleLibraryIds(userId: number, isSuperuser = false): Promise<number[]> {
if (isSuperuser) {
const rows = await this.db.select({ id: libraries.id }).from(libraries);
return rows.map((r) => r.id);
}
const rows = await this.db.select({ libraryId: userLibraryAccess.libraryId }).from(userLibraryAccess).where(eq(userLibraryAccess.userId, userId));
return rows.map((r) => r.libraryId);
}
async getAccessibleLibraries(userId: number, isSuperuser = false) {
if (isSuperuser) {
return this.db
.select({
id: libraries.id,
name: libraries.name,
bookCount: sql<number>`count(${books.id})::int`,
})
.from(libraries)
.leftJoin(books, and(eq(books.libraryId, libraries.id), eq(books.status, 'present')))
.groupBy(libraries.id)
.orderBy(libraries.name);
}
return this.db
.select({
id: libraries.id,
name: libraries.name,
bookCount: sql<number>`count(${books.id})::int`,
})
.from(libraries)
.innerJoin(userLibraryAccess, and(eq(userLibraryAccess.libraryId, libraries.id), eq(userLibraryAccess.userId, userId)))
.leftJoin(books, and(eq(books.libraryId, libraries.id), eq(books.status, 'present')))
.groupBy(libraries.id)
.orderBy(libraries.name);
}
async getBooksPage(
userId: number,
sortOrder: OpdsSortOrder,
page: number,
size: number,
filters?: { libraryId?: number; collectionId?: number; lensId?: number; author?: string; series?: string; q?: string },
isSuperuser = false,
): Promise<{ entries: OpdsBookEntry[]; total: number }> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return { entries: [], total: 0 };
if (filters?.libraryId && !accessibleIds.includes(filters.libraryId)) {
throw new ForbiddenException('No access to this library');
}
if (filters?.lensId) {
return this.getBooksByLens(userId, filters.lensId, accessibleIds, sortOrder, page, size);
}
const clauses: SQL[] = [inArray(books.libraryId, accessibleIds), eq(books.status, 'present')];
if (filters?.libraryId) clauses.push(eq(books.libraryId, filters.libraryId));
if (filters?.collectionId) {
clauses.push(
sql`${books.id} IN (SELECT ${collectionBooks.bookId} FROM ${collectionBooks} WHERE ${collectionBooks.collectionId} = ${filters.collectionId})`,
);
}
if (filters?.author) {
clauses.push(
sql`${books.id} IN (SELECT ${bookAuthors.bookId} FROM ${bookAuthors} INNER JOIN ${authors} ON ${authors.id} = ${bookAuthors.authorId} WHERE ${authors.name} = ${filters.author})`,
);
}
if (filters?.series) {
clauses.push(eq(bookMetadata.seriesName, filters.series));
}
if (filters?.q) {
clauses.push(sql`${bookMetadata.title} ILIKE ${'%' + filters.q + '%'}`);
}
return this.paginatedBookQuery(and(...clauses)!, sortOrder, page, size);
}
async getRecentBooksPage(userId: number, page: number, size: number, isSuperuser = false): Promise<{ entries: OpdsBookEntry[]; total: number }> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return { entries: [], total: 0 };
const where = and(inArray(books.libraryId, accessibleIds), eq(books.status, 'present'));
return this.paginatedBookQuery(where!, 'recent', page, size);
}
async getRandomBooks(userId: number, count: number, isSuperuser = false): Promise<OpdsBookEntry[]> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return [];
const ids = await this.db
.select({ id: books.id })
.from(books)
.where(and(inArray(books.libraryId, accessibleIds), eq(books.status, 'present')))
.orderBy(sql`random()`)
.limit(count);
if (ids.length === 0) return [];
return this.fetchBookEntries(ids.map((r) => r.id));
}
async getDistinctAuthors(userId: number, isSuperuser = false): Promise<{ name: string; bookCount: number }[]> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return [];
return this.db
.select({
name: authors.name,
bookCount: sql<number>`count(DISTINCT ${bookAuthors.bookId})::int`,
})
.from(authors)
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
.innerJoin(books, and(eq(books.id, bookAuthors.bookId), eq(books.status, 'present')))
.where(inArray(books.libraryId, accessibleIds))
.groupBy(authors.name)
.orderBy(sql`${authors.sortName} ASC NULLS LAST`);
}
async getDistinctSeries(userId: number, isSuperuser = false): Promise<{ name: string | null; bookCount: number }[]> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return [];
return this.db
.select({
name: bookMetadata.seriesName,
bookCount: sql<number>`count(DISTINCT ${books.id})::int`,
})
.from(bookMetadata)
.innerJoin(books, and(eq(books.id, bookMetadata.bookId), eq(books.status, 'present')))
.where(and(inArray(books.libraryId, accessibleIds), sql`${bookMetadata.seriesName} IS NOT NULL`))
.groupBy(bookMetadata.seriesName)
.orderBy(sql`${bookMetadata.seriesName} ASC`);
}
async getUserCollections(userId: number) {
return this.db
.select({
id: collections.id,
name: collections.name,
bookCount: sql<number>`count(${collectionBooks.bookId})::int`,
})
.from(collections)
.leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id))
.where(eq(collections.userId, userId))
.groupBy(collections.id)
.orderBy(collections.name);
}
async getUserLenses(userId: number) {
return this.db
.select({
id: lenses.id,
name: lenses.name,
icon: lenses.icon,
})
.from(lenses)
.where(eq(lenses.userId, userId))
.orderBy(lenses.name);
}
async validateBookAccess(bookId: number, userId: number, isSuperuser = false): Promise<void> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
const [row] = await this.db.select({ libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
if (!row || !accessibleIds.includes(row.libraryId)) {
throw new ForbiddenException('No access to this book');
}
}
async getBookFiles(bookId: number, fileId?: number): Promise<{ absolutePath: string; format: string; title: string; authorName: string } | null> {
const fileQuery = this.db
.select({
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
title: bookMetadata.title,
})
.from(bookFiles)
.leftJoin(books, eq(books.id, bookFiles.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(fileId ? eq(bookFiles.id, fileId) : and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'primary')))
.limit(1);
const [file] = await fileQuery;
if (!file) return null;
const [authorRow] = await this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(bookAuthors.displayOrder)
.limit(1);
return {
absolutePath: file.absolutePath,
format: file.format ?? 'unknown',
title: file.title ?? `book-${bookId}`,
authorName: authorRow?.name ?? '',
};
}
private async getBooksByLens(
userId: number,
lensId: number,
accessibleIds: number[],
sortOrder: OpdsSortOrder,
page: number,
size: number,
): Promise<{ entries: OpdsBookEntry[]; total: number }> {
const [lens] = await this.db.select().from(lenses).where(eq(lenses.id, lensId)).limit(1);
if (!lens) return { entries: [], total: 0 };
if (!lens.isPublic && lens.userId !== userId) return { entries: [], total: 0 };
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds: accessibleIds });
const statusClause = eq(books.status, 'present');
const combinedWhere = where ? and(where, statusClause) : statusClause;
return this.paginatedBookQuery(combinedWhere!, sortOrder, page, size);
}
private async paginatedBookQuery(
where: SQL,
sortOrder: OpdsSortOrder,
page: number,
size: number,
): Promise<{ entries: OpdsBookEntry[]; total: number }> {
const offset = (page - 1) * size;
const needsAuthorJoin = sortOrder === 'author_asc' || sortOrder === 'author_desc';
const idQuery = this.db.select({ id: books.id }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where).$dynamic();
if (needsAuthorJoin) {
idQuery.leftJoin(bookAuthors, eq(bookAuthors.bookId, books.id)).leftJoin(authors, eq(authors.id, bookAuthors.authorId));
}
const orderClauses = OPDS_SORT_MAP[sortOrder];
const [idRows, [{ total }]] = await Promise.all([
needsAuthorJoin
? this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookAuthors, eq(bookAuthors.bookId, books.id))
.leftJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(where)
.groupBy(books.id, bookMetadata.title, bookMetadata.seriesName, bookMetadata.seriesIndex)
.orderBy(...orderClauses)
.limit(size)
.offset(offset)
: this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where)
.orderBy(...orderClauses)
.limit(size)
.offset(offset),
this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
]);
if (idRows.length === 0) return { entries: [], total: Number(total) };
const entries = await this.fetchBookEntries(idRows.map((r) => r.id));
return { entries, total: Number(total) };
}
private async fetchBookEntries(bookIds: number[]): Promise<OpdsBookEntry[]> {
if (bookIds.length === 0) return [];
const [metaRows, authorRows, fileRows] = await Promise.all([
this.db
.select({
id: books.id,
folderPath: books.folderPath,
addedAt: books.addedAt,
bookUpdatedAt: books.updatedAt,
title: bookMetadata.title,
description: bookMetadata.description,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
language: bookMetadata.language,
publisher: bookMetadata.publisher,
isbn13: bookMetadata.isbn13,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
.from(bookFiles)
.where(and(inArray(bookFiles.bookId, bookIds), eq(bookFiles.role, 'primary'))),
]);
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format ?? 'unknown' });
filesByBook.set(row.bookId, list);
}
const idOrder = new Map(bookIds.map((id, i) => [id, i]));
return metaRows
.map((row) => ({
id: row.id,
title: row.title ?? row.folderPath.split('/').pop() ?? 'Untitled',
folderPath: row.folderPath,
addedAt: row.addedAt,
updatedAt: row.bookUpdatedAt,
description: row.description,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
language: row.language,
publisher: row.publisher,
isbn13: row.isbn13,
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
}))
.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
}
}
@@ -0,0 +1,17 @@
import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common';
import { AppSettingsService } from '../app-settings/app-settings.service';
@Injectable()
export class OpdsEnabledGuard implements CanActivate {
constructor(private readonly appSettingsService: AppSettingsService) {}
async canActivate(): Promise<boolean> {
const settings = await this.appSettingsService.findAll();
const opdsRow = settings.find((s) => s.key === 'opds_enabled');
if (opdsRow?.value !== 'true') {
throw new ForbiddenException('OPDS server is disabled');
}
return true;
}
}
@@ -0,0 +1,35 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { CreateOpdsUserDto } from './dto/create-opds-user.dto';
import { UpdateOpdsUserDto } from './dto/update-opds-user.dto';
import { OpdsUserService } from './opds-user.service';
@Controller('v1/opds-users')
@RequirePermission('opds_access')
export class OpdsUserController {
constructor(private readonly opdsUserService: OpdsUserService) {}
@Get()
findAll(@CurrentUser() user: RequestUser) {
return this.opdsUserService.findAllForUser(user.id);
}
@Post()
create(@CurrentUser() user: RequestUser, @Body() dto: CreateOpdsUserDto) {
return this.opdsUserService.create(user.id, dto);
}
@Patch(':id')
update(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOpdsUserDto) {
return this.opdsUserService.update(user.id, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
delete(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
return this.opdsUserService.delete(user.id, id);
}
}
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { OpdsRequestUser } from './opds-auth.guard';
export const OpdsUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): OpdsRequestUser => ctx.switchToHttp().getRequest<{ opdsUser: OpdsRequestUser }>().opdsUser,
);
@@ -0,0 +1,99 @@
import { ConflictException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { compare, hash } from 'bcryptjs';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { CreateOpdsUserDto } from './dto/create-opds-user.dto';
import { UpdateOpdsUserDto } from './dto/update-opds-user.dto';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class OpdsUserService {
constructor(@Inject(DB) private readonly db: Db) {}
findAllForUser(userId: number) {
return this.db
.select({
id: schema.opdsUsers.id,
userId: schema.opdsUsers.userId,
username: schema.opdsUsers.username,
sortOrder: schema.opdsUsers.sortOrder,
createdAt: schema.opdsUsers.createdAt,
})
.from(schema.opdsUsers)
.where(eq(schema.opdsUsers.userId, userId))
.orderBy(schema.opdsUsers.username);
}
async create(userId: number, dto: CreateOpdsUserDto) {
const passwordHash = await hash(dto.password, 12);
try {
const [created] = await this.db
.insert(schema.opdsUsers)
.values({
userId,
username: dto.username,
passwordHash,
sortOrder: dto.sortOrder ?? 'recent',
})
.returning({
id: schema.opdsUsers.id,
userId: schema.opdsUsers.userId,
username: schema.opdsUsers.username,
sortOrder: schema.opdsUsers.sortOrder,
createdAt: schema.opdsUsers.createdAt,
});
return created;
} catch (err: unknown) {
if (err instanceof Error && 'code' in err && (err as { code: string }).code === '23505') {
throw new ConflictException('An OPDS user with this username already exists');
}
throw err;
}
}
async update(userId: number, opdsUserId: number, dto: UpdateOpdsUserDto) {
await this.verifyOwnership(userId, opdsUserId);
const [updated] = await this.db.update(schema.opdsUsers).set({ sortOrder: dto.sortOrder }).where(eq(schema.opdsUsers.id, opdsUserId)).returning({
id: schema.opdsUsers.id,
userId: schema.opdsUsers.userId,
username: schema.opdsUsers.username,
sortOrder: schema.opdsUsers.sortOrder,
createdAt: schema.opdsUsers.createdAt,
});
if (!updated) throw new NotFoundException('OPDS user not found');
return updated;
}
async delete(userId: number, opdsUserId: number) {
await this.verifyOwnership(userId, opdsUserId);
await this.db.delete(schema.opdsUsers).where(eq(schema.opdsUsers.id, opdsUserId));
}
async validateCredentials(username: string, password: string) {
const opdsUser = await this.db.query.opdsUsers.findFirst({
where: eq(schema.opdsUsers.username, username),
});
if (!opdsUser) return null;
const valid = await compare(password, opdsUser.passwordHash);
if (!valid) return null;
const parentUser = await this.db.query.users.findFirst({
where: eq(schema.users.id, opdsUser.userId),
});
if (!parentUser) return null;
return { opdsUser, parentUser };
}
private async verifyOwnership(userId: number, opdsUserId: number) {
const row = await this.db.query.opdsUsers.findFirst({
where: and(eq(schema.opdsUsers.id, opdsUserId), eq(schema.opdsUsers.userId, userId)),
});
if (!row) throw new ForbiddenException('Not the owner of this OPDS user');
}
}
@@ -0,0 +1,38 @@
export function esc(s: string | null | undefined): string {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
export function xmlEl(tag: string, text: string | null | undefined): string {
return `<${tag}>${esc(text)}</${tag}>`;
}
export function xmlLink(rel: string, href: string, type: string, title?: string): string {
const t = title ? ` title="${esc(title)}"` : '';
return `<link rel="${esc(rel)}" href="${esc(href)}" type="${esc(type)}"${t}/>`;
}
export function fileMimeType(format: string): string {
switch (format.toLowerCase()) {
case 'epub':
return 'application/epub+zip';
case 'pdf':
return 'application/pdf';
case 'mobi':
return 'application/x-mobipocket-ebook';
case 'azw3':
return 'application/vnd.amazon.ebook';
case 'fb2':
return 'application/x-fictionbook+xml';
case 'cbz':
return 'application/vnd.comicbook+zip';
case 'cbr':
return 'application/vnd.comicbook-rar';
default:
return 'application/octet-stream';
}
}
export const OPDS_MIME_NAV = 'application/atom+xml;profile=opds-catalog;kind=navigation';
export const OPDS_MIME_ACQ = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
export const OPDS_MIME_SEARCH = 'application/opensearchdescription+xml';
+257
View File
@@ -0,0 +1,257 @@
import { Controller, DefaultValuePipe, Get, Headers, NotFoundException, Param, ParseIntPipe, Query, Res, UseGuards } from '@nestjs/common';
import { createReadStream } from 'fs';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import { ConfigService } from '@nestjs/config';
import type { FastifyReply } from 'fastify';
import { Public } from '../../common/decorators/public.decorator';
import { OPDS_MIME_ACQ, OPDS_MIME_NAV, OPDS_MIME_SEARCH, fileMimeType } from './opds-xml.helpers';
import { OpdsAuthGuard } from './opds-auth.guard';
import type { OpdsRequestUser } from './opds-auth.guard';
import { OpdsEnabledGuard } from './opds-enabled.guard';
import { OpdsUser } from './opds-user.decorator';
import { OpdsBookService } from './opds-book.service';
import { OpdsService } from './opds.service';
@Controller('v1/opds')
@Public()
@UseGuards(OpdsEnabledGuard, OpdsAuthGuard)
export class OpdsController {
private readonly booksPath: string;
constructor(
private readonly opdsService: OpdsService,
private readonly opdsBookService: OpdsBookService,
private readonly config: ConfigService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
}
@Get()
root(@OpdsUser() _user: OpdsRequestUser, @Res() reply: FastifyReply) {
const xml = this.opdsService.generateRootNavigation();
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('libraries')
async libraries(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const libs = await this.opdsBookService.getAccessibleLibraries(user.userId, user.isSuperuser);
const xml = this.opdsService.generateLibrariesNavigation(libs);
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('collections')
async collections(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const cols = await this.opdsBookService.getUserCollections(user.userId);
const xml = this.opdsService.generateCollectionsNavigation(cols);
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('lenses')
async lenses(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const items = await this.opdsBookService.getUserLenses(user.userId);
const xml = this.opdsService.generateLensesNavigation(items);
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('authors')
async authors(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const items = await this.opdsBookService.getDistinctAuthors(user.userId, user.isSuperuser);
const xml = this.opdsService.generateAuthorsNavigation(items);
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('series')
async series(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const items = await this.opdsBookService.getDistinctSeries(user.userId, user.isSuperuser);
const xml = this.opdsService.generateSeriesNavigation(items.filter((s): s is { name: string; bookCount: number } => s.name !== null));
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@Get('catalog')
async catalog(
@OpdsUser() user: OpdsRequestUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
@Query('libraryId') libraryIdStr?: string,
@Query('collectionId') collectionIdStr?: string,
@Query('lensId') lensIdStr?: string,
@Query('author') author?: string,
@Query('series') series?: string,
@Query('q') q?: string,
@Res() reply?: FastifyReply,
) {
const clampedSize = Math.min(Math.max(size, 1), 100);
const clampedPage = Math.max(page, 1);
const filters: Record<string, string | number> = {};
if (libraryIdStr) filters.libraryId = parseInt(libraryIdStr, 10);
if (collectionIdStr) filters.collectionId = parseInt(collectionIdStr, 10);
if (lensIdStr) filters.lensId = parseInt(lensIdStr, 10);
if (author) filters.author = author;
if (series) filters.series = series;
if (q) filters.q = q;
const { entries, total } = await this.opdsBookService.getBooksPage(
user.userId,
user.sortOrder,
clampedPage,
clampedSize,
filters,
user.isSuperuser,
);
const selfParams = new URLSearchParams();
for (const [k, v] of Object.entries(filters)) selfParams.set(k, String(v));
selfParams.set('page', String(clampedPage));
selfParams.set('size', String(clampedSize));
const selfPath = `/api/v1/opds/catalog?${selfParams.toString()}`;
const feedTitle = q ? `Search: ${q}` : 'Catalog';
const xml = this.opdsService.generateAcquisitionFeed(
feedTitle,
'urn:projectx:catalog',
entries,
total,
clampedPage,
clampedSize,
selfPath,
user.coverToken,
);
this.sendXml(reply!, xml, OPDS_MIME_ACQ);
}
@Get('recent')
async recent(
@OpdsUser() user: OpdsRequestUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(50), ParseIntPipe) size: number,
@Res() reply?: FastifyReply,
) {
const clampedSize = Math.min(Math.max(size, 1), 100);
const clampedPage = Math.max(page, 1);
const { entries, total } = await this.opdsBookService.getRecentBooksPage(user.userId, clampedPage, clampedSize, user.isSuperuser);
const selfPath = `/api/v1/opds/recent?page=${clampedPage}&size=${clampedSize}`;
const xml = this.opdsService.generateAcquisitionFeed(
'Recent Books',
'urn:projectx:recent',
entries,
total,
clampedPage,
clampedSize,
selfPath,
user.coverToken,
);
this.sendXml(reply!, xml, OPDS_MIME_ACQ);
}
@Get('surprise')
async surprise(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const entries = await this.opdsBookService.getRandomBooks(user.userId, 25, user.isSuperuser);
const xml = this.opdsService.generateAcquisitionFeed(
'Random Books',
'urn:projectx:surprise',
entries,
entries.length,
1,
25,
'/api/v1/opds/surprise',
user.coverToken,
);
this.sendXml(reply, xml, OPDS_MIME_ACQ);
}
@Get('search.opds')
searchDescription(@OpdsUser() _user: OpdsRequestUser, @Res() reply: FastifyReply) {
const xml = this.opdsService.generateOpenSearchDescription();
reply.type(`${OPDS_MIME_SEARCH}; charset=utf-8`).send(xml);
}
@Get(':bookId/cover')
async cover(
@Param('bookId', ParseIntPipe) bookId: number,
@OpdsUser() user: OpdsRequestUser,
@Res() reply: FastifyReply,
@Headers('if-none-match') ifNoneMatch?: string,
) {
await this.opdsBookService.validateBookAccess(bookId, user.userId, user.isSuperuser);
const dir = join(this.booksPath, 'covers', String(bookId));
try {
const files = await readdir(dir);
const cover = files.find((f) => f.startsWith('cover.'));
if (!cover) throw new NotFoundException('No cover');
const coverPath = join(dir, cover);
const { mtimeMs } = await stat(coverPath);
const etag = `"${Math.floor(mtimeMs)}"`;
if (ifNoneMatch === etag) {
reply.status(304).send();
return;
}
const ext = cover.split('.').pop()?.toLowerCase();
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
reply.type(ext === 'png' ? 'image/png' : 'image/jpeg');
reply.send(createReadStream(coverPath));
} catch {
throw new NotFoundException('No cover');
}
}
@Get(':bookId/thumbnail')
async thumbnail(
@Param('bookId', ParseIntPipe) bookId: number,
@OpdsUser() user: OpdsRequestUser,
@Res() reply: FastifyReply,
@Headers('if-none-match') ifNoneMatch?: string,
) {
await this.opdsBookService.validateBookAccess(bookId, user.userId, user.isSuperuser);
const thumbnailPath = join(this.booksPath, 'covers', String(bookId), 'thumbnail.jpg');
try {
const { mtimeMs } = await stat(thumbnailPath);
const etag = `"${Math.floor(mtimeMs)}"`;
if (ifNoneMatch === etag) {
reply.status(304).send();
return;
}
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
reply.type('image/jpeg');
reply.send(createReadStream(thumbnailPath));
} catch {
throw new NotFoundException('No thumbnail');
}
}
@Get(':bookId/download')
async download(
@Param('bookId', ParseIntPipe) bookId: number,
@Query('fileId', new DefaultValuePipe(0), ParseIntPipe) fileId: number,
@OpdsUser() user: OpdsRequestUser,
@Res() reply: FastifyReply,
) {
await this.opdsBookService.validateBookAccess(bookId, user.userId, user.isSuperuser);
const bookFiles = await this.opdsBookService.getBookFiles(bookId, fileId);
if (!bookFiles) throw new NotFoundException('File not found');
const { absolutePath, format, title, authorName } = bookFiles;
const { size: fileSize } = await stat(absolutePath);
const mime = fileMimeType(format);
const safeName =
[title, authorName]
.filter(Boolean)
.join(' - ')
.replace(/[^\w\s.-]/g, '') || `book-${bookId}`;
reply.header('Content-Disposition', `attachment; filename="${safeName}.${format}"`);
reply.header('Content-Length', fileSize);
reply.type(mime);
reply.send(createReadStream(absolutePath));
}
private sendXml(reply: FastifyReply, xml: string, mimeType: string) {
reply.type(`${mimeType}; charset=utf-8`).send(xml);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { BookModule } from '../book/book.module';
import { UserModule } from '../user/user.module';
import { CommonModule } from '../../common/common.module';
import { OpdsAuthGuard } from './opds-auth.guard';
import { OpdsBookService } from './opds-book.service';
import { OpdsController } from './opds.controller';
import { OpdsEnabledGuard } from './opds-enabled.guard';
import { OpdsService } from './opds.service';
import { OpdsUserController } from './opds-user.controller';
import { OpdsUserService } from './opds-user.service';
@Module({
imports: [AppSettingsModule, BookModule, UserModule, CommonModule],
controllers: [OpdsController, OpdsUserController],
providers: [OpdsService, OpdsBookService, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard],
})
export class OpdsModule {}
+221
View File
@@ -0,0 +1,221 @@
import { Injectable } from '@nestjs/common';
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
import type { OpdsBookEntry } from './opds-book.service';
const BASE = '/api/v1/opds';
@Injectable()
export class OpdsService {
generateRootNavigation(): string {
const now = new Date().toISOString();
return this.wrapFeed(
'projectx OPDS Catalog',
'urn:projectx:root',
now,
[xmlLink('self', BASE, OPDS_MIME_NAV), xmlLink('start', BASE, OPDS_MIME_NAV), xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH)],
[
this.navEntry('urn:projectx:all', 'All Books', 'Browse the full catalog', `${BASE}/catalog`, now),
this.navEntry('urn:projectx:recent', 'Recent Books', 'Recently added books', `${BASE}/recent`, now),
this.navEntry('urn:projectx:surprise', 'Random Books', '25 random picks', `${BASE}/surprise`, now),
this.navEntry('urn:projectx:libraries', 'Libraries', 'Browse by library', `${BASE}/libraries`, now),
this.navEntry('urn:projectx:collections', 'Collections', 'Browse your collections', `${BASE}/collections`, now),
this.navEntry('urn:projectx:lenses', 'Lenses', 'Browse your lenses', `${BASE}/lenses`, now),
this.navEntry('urn:projectx:authors', 'Authors', 'Browse by author', `${BASE}/authors`, now),
this.navEntry('urn:projectx:series', 'Series', 'Browse by series', `${BASE}/series`, now),
],
);
}
generateLibrariesNavigation(libs: { id: number; name: string; bookCount: number }[]): string {
const now = new Date().toISOString();
const entries = libs.map((lib) =>
this.navEntry(`urn:projectx:library:${lib.id}`, lib.name, `${lib.bookCount} books`, `${BASE}/catalog?libraryId=${lib.id}`, now),
);
return this.wrapFeed('Libraries', 'urn:projectx:libraries', now, [xmlLink('self', `${BASE}/libraries`, OPDS_MIME_NAV)], entries);
}
generateCollectionsNavigation(cols: { id: number; name: string; bookCount: number }[]): string {
const now = new Date().toISOString();
const entries = cols.map((col) =>
this.navEntry(`urn:projectx:collection:${col.id}`, col.name, `${col.bookCount} books`, `${BASE}/catalog?collectionId=${col.id}`, now),
);
return this.wrapFeed('Collections', 'urn:projectx:collections', now, [xmlLink('self', `${BASE}/collections`, OPDS_MIME_NAV)], entries);
}
generateLensesNavigation(items: { id: number; name: string; icon: string | null }[]): string {
const now = new Date().toISOString();
const entries = items.map((lens) =>
this.navEntry(`urn:projectx:lens:${lens.id}`, lens.name, 'Dynamic lens', `${BASE}/catalog?lensId=${lens.id}`, now),
);
return this.wrapFeed('Lenses', 'urn:projectx:lenses', now, [xmlLink('self', `${BASE}/lenses`, OPDS_MIME_NAV)], entries);
}
generateAuthorsNavigation(items: { name: string; bookCount: number }[]): string {
const now = new Date().toISOString();
const entries = items.map((a) =>
this.navEntry(
`urn:projectx:author:${encodeURIComponent(a.name)}`,
a.name,
`${a.bookCount} books`,
`${BASE}/catalog?author=${encodeURIComponent(a.name)}`,
now,
),
);
return this.wrapFeed('Authors', 'urn:projectx:authors', now, [xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV)], entries);
}
generateSeriesNavigation(items: { name: string; bookCount: number }[]): string {
const now = new Date().toISOString();
const entries = items.map((s) =>
this.navEntry(
`urn:projectx:series:${encodeURIComponent(s.name)}`,
s.name,
`${s.bookCount} books`,
`${BASE}/catalog?series=${encodeURIComponent(s.name)}`,
now,
),
);
return this.wrapFeed('Series', 'urn:projectx:series', now, [xmlLink('self', `${BASE}/series`, OPDS_MIME_NAV)], entries);
}
generateAcquisitionFeed(
title: string,
feedId: string,
books: OpdsBookEntry[],
total: number,
page: number,
size: number,
selfPath: string,
coverToken: string,
): string {
const now = new Date().toISOString();
const totalPages = Math.max(1, Math.ceil(total / size));
const links = [
xmlLink('self', selfPath, OPDS_MIME_ACQ),
xmlLink('start', BASE, OPDS_MIME_NAV),
xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH),
];
const url = new URL(selfPath, 'http://localhost');
const pageUrl = (p: number) => {
url.searchParams.set('page', String(p));
return `${url.pathname}?${url.searchParams.toString()}`;
};
if (page > 1) {
links.push(xmlLink('first', pageUrl(1), OPDS_MIME_ACQ));
links.push(xmlLink('previous', pageUrl(page - 1), OPDS_MIME_ACQ));
}
if (page < totalPages) {
links.push(xmlLink('next', pageUrl(page + 1), OPDS_MIME_ACQ));
links.push(xmlLink('last', pageUrl(totalPages), OPDS_MIME_ACQ));
}
const entries = books.map((book) => this.bookEntry(book, coverToken));
return this.wrapFeed(title, feedId, now, links, entries, total);
}
generateOpenSearchDescription(): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">',
` ${xmlEl('ShortName', 'projectx OPDS')}`,
` ${xmlEl('Description', 'Search the projectx book catalog')}`,
` <Url type="${esc(OPDS_MIME_ACQ)}" template="${esc(BASE)}/catalog?q={searchTerms}"/>`,
' <InputEncoding>UTF-8</InputEncoding>',
' <OutputEncoding>UTF-8</OutputEncoding>',
'</OpenSearchDescription>',
].join('\n');
}
private bookEntry(book: OpdsBookEntry, coverToken: string): string {
const lines: string[] = [];
lines.push('<entry>');
lines.push(` ${xmlEl('title', book.title)}`);
lines.push(` ${xmlEl('id', `urn:projectx:book:${book.id}`)}`);
lines.push(` ${xmlEl('updated', book.updatedAt.toISOString())}`);
for (const author of book.authors) {
lines.push(` <author>${xmlEl('name', author)}</author>`);
}
if (book.description) {
lines.push(` <content type="text">${esc(book.description)}</content>`);
} else {
lines.push(' <content type="text"/>');
}
if (book.seriesName) {
lines.push(
` <link rel="http://opds-spec.org/sort/series" href="${esc(BASE)}/catalog?series=${encodeURIComponent(book.seriesName)}" title="${esc(book.seriesName)}${book.seriesIndex != null ? ` #${book.seriesIndex}` : ''}"/>`,
);
}
if (book.language) {
lines.push(` ${xmlEl('dc:language', book.language)}`);
}
if (book.publisher) {
lines.push(` ${xmlEl('dc:publisher', book.publisher)}`);
}
if (book.isbn13) {
lines.push(` ${xmlEl('dc:identifier', `urn:isbn:${esc(book.isbn13)}`)}`);
}
lines.push(` ${xmlLink('http://opds-spec.org/image', `${BASE}/${book.id}/cover?t=${encodeURIComponent(coverToken)}`, 'image/jpeg')}`);
lines.push(
` ${xmlLink('http://opds-spec.org/image/thumbnail', `${BASE}/${book.id}/thumbnail?t=${encodeURIComponent(coverToken)}`, 'image/jpeg')}`,
);
for (const file of book.files) {
const mime = fileMimeType(file.format);
lines.push(
` ${xmlLink('http://opds-spec.org/acquisition', `${BASE}/${book.id}/download?fileId=${file.id}`, mime, file.format.toUpperCase())}`,
);
}
lines.push('</entry>');
return lines.join('\n');
}
private navEntry(id: string, title: string, content: string, href: string, updated: string): string {
return [
'<entry>',
` ${xmlEl('title', title)}`,
` ${xmlEl('id', id)}`,
` ${xmlEl('updated', updated)}`,
` <content type="text">${esc(content)}</content>`,
` ${xmlLink('subsection', href, OPDS_MIME_NAV)}`,
'</entry>',
].join('\n');
}
private wrapFeed(title: string, id: string, updated: string, links: string[], entries: string[], totalResults?: number): string {
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<feed xmlns="http://www.w3.org/2005/Atom"',
' xmlns:dc="http://purl.org/dc/terms/"',
' xmlns:opds="http://opds-spec.org/2010/catalog"',
' xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">',
` ${xmlEl('title', title)}`,
` ${xmlEl('id', id)}`,
` ${xmlEl('updated', updated)}`,
];
if (totalResults !== undefined) {
lines.push(` ${xmlEl('opensearch:totalResults', String(totalResults))}`);
}
for (const link of links) {
lines.push(` ${link}`);
}
for (const entry of entries) {
lines.push(entry);
}
lines.push('</feed>');
return lines.join('\n');
}
}
+5 -1
View File
@@ -16,6 +16,7 @@ const BUILT_IN_PERMISSIONS = [
{ name: 'library_edit_metadata', description: 'Edit book info', isSystem: true },
{ name: 'library_delete_books', description: 'Delete books from a library', isSystem: true },
{ name: 'kobo_sync', description: 'Sync with Kobo device', isSystem: true },
{ name: 'opds_access', description: 'Access the OPDS catalog', isSystem: true },
];
const ADMIN_PERMISSIONS = [
@@ -28,9 +29,10 @@ const ADMIN_PERMISSIONS = [
'library_edit_metadata',
'library_delete_books',
'kobo_sync',
'opds_access',
];
const USER_PERMISSIONS = ['library_upload', 'library_download', 'library_edit_metadata', 'library_delete_books', 'kobo_sync'];
const USER_PERMISSIONS = ['library_upload', 'library_download', 'library_edit_metadata', 'library_delete_books', 'kobo_sync', 'opds_access'];
@Injectable()
export class SeedService implements OnApplicationBootstrap {
@@ -94,6 +96,8 @@ export class SeedService implements OnApplicationBootstrap {
.values({ key: 'allow_registration', value: 'false' })
.onConflictDoNothing({ target: schema.appSettings.key });
await this.db.insert(schema.appSettings).values({ key: 'opds_enabled', value: 'true' }).onConflictDoNothing({ target: schema.appSettings.key });
const defaultOidcConfig = JSON.stringify({
enabled: false,
issuerUri: '',