mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(opds): implement OPDS catalog with cover token auth
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./library";
|
||||
export * from "./query";
|
||||
export * from "./reader-settings";
|
||||
export * from "./scanner";
|
||||
export * from "./opds";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+13
@@ -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": {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './metadata';
|
||||
export * from './scanner';
|
||||
export * from './reader';
|
||||
export * from './oidc';
|
||||
export * from './opds';
|
||||
|
||||
@@ -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 & b < c > d " e ' 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 & 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 "Library""');
|
||||
});
|
||||
|
||||
it('escapes href', () => {
|
||||
const result = xmlLink('self', '/feed?a=1&b=2', 'text/xml');
|
||||
expect(result).toContain('href="/feed?a=1&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&page=1`);
|
||||
expect(xml).toContain(`${BASE}/catalog?size=10&page=3`);
|
||||
expect(xml).toContain(`${BASE}/catalog?size=10&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&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('&');
|
||||
expect(xml).toContain('<');
|
||||
expect(xml).toContain('>');
|
||||
expect(xml).toContain('"');
|
||||
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('&');
|
||||
expect(xml).toContain(''');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user