feat: improve header UI and optimize docker storage configuration

This commit is contained in:
neon
2026-04-08 20:34:38 -06:00
parent 1cf34fb0ab
commit 26d1e6ebc8
50 changed files with 518 additions and 238 deletions
+15
View File
@@ -4,6 +4,18 @@ APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Host folder that appears as /books in the container.
# Point this at your library root; use an absolute path on NAS/Portainer installs.
BOOKS_HOST_PATH=./books
# Container runtime UID/GID for files written to app-managed data folders.
# Most users can leave these defaults.
PUID=1000
PGID=1000
# Node.js JavaScript heap limit in MB. Raise this for very large libraries.
NODE_MAX_OLD_SPACE_SIZE=2048
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
@@ -28,6 +40,9 @@ APP_URL=https://projectx.example.com
# CORS origin for the frontend when served from a different domain than APP_URL
# CLIENT_URL=https://app.example.com
# Set to false only if your platform manages bind mount ownership externally.
# PROJECTX_FIX_PERMISSIONS=true
# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32)
# EMAIL_ENCRYPTION_KEY=
+2 -4
View File
@@ -41,7 +41,7 @@ FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
RUN apk upgrade --no-cache && \
apk add --no-cache poppler-utils && \
apk add --no-cache poppler-utils su-exec && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
ENV NODE_ENV=production
@@ -51,9 +51,7 @@ COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /book-bucket /data /tmp && chown -R node:node /books /book-bucket /data /tmp
USER node
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp
EXPOSE 3000
+32 -4
View File
@@ -55,7 +55,7 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
2. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
3. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
> **Where does data go?** `BOOKS_PATH` in `server/.env` defaults to `../local/data` (resolves to `<project-root>/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored.
> **Where does data go?** `APP_DATA_PATH` in `server/.env` defaults to `../local/data` (resolves to `<project-root>/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored.
---
@@ -212,14 +212,15 @@ The fastest way to learn the patterns is to follow an existing module:
Server environment is configured in `server/.env` for local development. Production compose uses the repo-root `.env` created from `.env.example`.
| Variable | Default | Description |
| ----------------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
| ------------------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string |
| `PORT` | `3000` | Server port |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` |
| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) |
| `APP_DATA_PATH` | `../local/data` | App data directory for cover images (not where books live) |
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
| `NODE_MAX_OLD_SPACE_SIZE` | `2048` | Docker Node.js heap limit in MB |
## Production Deploy
@@ -233,10 +234,31 @@ Quick start:
```bash
cp .env.example .env
# edit .env and set APP_IMAGE + secrets
# edit .env and set APP_IMAGE, secrets, APP_URL, and BOOKS_HOST_PATH
pnpm prod:up
```
For production compose, `BOOKS_HOST_PATH` is the only host folder most users need to choose. It is mounted as `/books` in the container, and library paths created in the UI should point under `/books`.
Generated app data and PostgreSQL live in explicit folders beside the compose file:
```text
./data/app generated covers, author images, staging files, and Book Bucket uploads
./data/postgres PostgreSQL data
```
The container starts as root only long enough to create/chown app-managed data paths, then runs the Node process as `PUID:PGID`.
On NAS installs, set `PUID` and `PGID` to the user/group that should own app-generated files:
```bash
id your-media-user
```
Do not add a compose `user:` override unless your platform requires it; that bypasses the startup permission repair.
The app container defaults Node's JavaScript heap limit to 2048 MB. Set `NODE_MAX_OLD_SPACE_SIZE` in `.env` if the host needs a lower or higher limit.
---
## Database Workflow
@@ -331,6 +353,12 @@ docker compose up -d --wait
cd server && pnpm db:migrate
```
### `EACCES: permission denied, mkdir '/data/covers'`
The default compose uses `./data/app` for generated app data. The container repairs `/data` ownership during startup, then runs as `PUID:PGID`.
If this still happens, make sure the folder mounted at `/data` is writable by `PUID:PGID`. Avoid adding `user:` to the app service, because startup cannot repair ownership when it is forced to start as a non-root user.
### `Cannot find module '@projectx/types'`
The shared types package needs to be built or the workspace link is broken:
+34 -16
View File
@@ -30,6 +30,7 @@ import { useLibraryUploadEvents } from '@/features/library/composables/useLibrar
import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types'
import { useThemeStore } from '@/stores/theme'
const router = useRouter()
const route = useRoute()
@@ -38,10 +39,13 @@ const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary()
const themeStore = useThemeStore()
const isBookBucketActive = computed(() => route.name === 'book-bucket')
const isStatisticsActive = computed(() => route.name === 'statistics')
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
function navigateToBookBucket() {
router.push({ name: 'book-bucket' })
}
@@ -147,7 +151,7 @@ onMounted(() => {
}
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0 && event.failedCount === 0) return
const uploadedLabel = `${event.uploadedCount} book${event.uploadedCount === 1 ? '' : 's'}`
@@ -167,7 +171,7 @@ const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
stopUploadCompletedListener()
stopLibraryUploadListener()
})
function highlightSegments(text: string | null, query: string) {
@@ -300,7 +304,7 @@ function formatBadgeClass(fmt: string): string {
<!-- Normal state -->
<template v-else>
<!-- Left: sidebar trigger -->
<SidebarTrigger class="-ml-1 text-foreground/70 hover:text-foreground" />
<SidebarTrigger :class="['-ml-1 text-foreground/70 hover:text-foreground', iconRadiusClass]" />
<Separator orientation="vertical" class="mx-1 h-4" />
<!-- Center: desktop global search -->
@@ -409,7 +413,10 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="md:hidden h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
:class="[
'md:hidden h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
iconRadiusClass,
]"
@click="mobileSearchOpen = true"
>
<Search :size="15" />
@@ -426,12 +433,13 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 rounded-xl border transition-colors"
:class="
class="relative h-8 w-8 border transition-colors"
:class="[
isBookBucketActive
? 'border-primary/80 bg-primary/8 text-primary'
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground'
"
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground',
iconRadiusClass,
]"
@click="navigateToBookBucket"
>
<PackageOpen :size="15" />
@@ -452,12 +460,13 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border transition-colors"
:class="
class="h-8 w-8 border transition-colors"
:class="[
isStatisticsActive
? 'border-primary/80 bg-primary/8 text-primary'
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground'
"
: 'border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground',
iconRadiusClass,
]"
@click="navigateToStatistics"
>
<BarChart3 :size="15" />
@@ -472,7 +481,10 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
:class="[
'h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
iconRadiusClass,
]"
@click="uploadOpen = true"
>
<Upload :size="15" />
@@ -492,7 +504,7 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
:class="['h-8 w-8 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors', iconRadiusClass]"
>
<Palette :size="15" />
</Button>
@@ -528,7 +540,10 @@ function formatBadgeClass(fmt: string): string {
<Button
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
:class="[
'h-8 w-8 border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors',
iconRadiusClass,
]"
@click="navigateToSettings"
>
<Settings :size="15" />
@@ -543,7 +558,10 @@ function formatBadgeClass(fmt: string): string {
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<button
class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-primary/50 bg-primary/10 hover:bg-primary/15 hover:border-primary/70 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden border border-primary/50 bg-primary/10 hover:bg-primary/15 hover:border-primary/70 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
iconRadiusClass,
]"
>
<UserAvatar :name="user.name" :avatar-url="user.avatarUrl ?? null" size-class="h-full w-full" />
</button>
+8 -3
View File
@@ -26,6 +26,7 @@ import type { Library } from '@projectx/types'
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useThemeStore } from '@/stores/theme'
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
@@ -48,6 +49,9 @@ const { lenses, fetchLenses, reorderLenses } = useLenses()
const { collections, fetchCollections, reorderCollections } = useCollections()
const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const themeStore = useThemeStore()
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const refreshedFor = new Set<number>()
watch(progressMap, (map) => {
@@ -136,13 +140,13 @@ onMounted(async () => {
fetchCollections()
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount > 0) {
refreshLibraries()
}
})
onUnmounted(() => stopUploadCompletedListener())
onUnmounted(() => stopLibraryUploadListener())
</script>
<template>
@@ -157,7 +161,8 @@ onUnmounted(() => stopUploadCompletedListener())
>
<!-- Logo mark -->
<div
class="flex h-8.5 w-8.5 shrink-0 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/5 dark:ring-white/10"
class="flex h-8.5 w-8.5 shrink-0 items-center justify-center shadow-sm ring-1 ring-black/5 dark:ring-white/10"
:class="iconRadiusClass"
style="background: linear-gradient(135deg, var(--primary) 0%, color-mix(in oklch, var(--primary) 75%, transparent) 100%)"
>
<svg viewBox="0 0 20 20" fill="none" class="h-4 w-4" aria-hidden="true">
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown, Plus, GripVertical, Check, MoreVertical } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{
label: string
@@ -14,6 +16,9 @@ const props = defineProps<{
const emit = defineEmits<{ toggle: []; add: []; toggleReorder: [] }>()
const themeStore = useThemeStore()
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const hasMenu = () => props.canAdd || props.canReorder
</script>
@@ -40,8 +45,8 @@ const hasMenu = () => props.canAdd || props.canReorder
</button>
<DropdownMenu v-if="hasMenu()">
<DropdownMenuTrigger
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-sidebar-accent"
:class="isReordering ? 'text-primary' : 'text-sidebar-foreground/45 hover:text-sidebar-foreground/90'"
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center transition-colors hover:bg-sidebar-accent"
:class="[isReordering ? 'text-primary' : 'text-sidebar-foreground/45 hover:text-sidebar-foreground/90', iconRadiusClass]"
@click.stop
>
<MoreVertical :size="15" :stroke-width="2.2" />
@@ -1,15 +1,49 @@
import { ref } from 'vue'
import type { AuthUser, AuthResponse } from '@projectx/types'
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
import { useSetupStatus } from './useSetupStatus'
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
const user = ref<AuthUser | null>(null)
const isLoading = ref(false)
let sessionRefreshTimer: number | null = null
function canRefreshSession() {
return user.value && (typeof document === 'undefined' || document.visibilityState === 'visible')
}
function stopSessionRefresh() {
if (sessionRefreshTimer !== null) {
window.clearInterval(sessionRefreshTimer)
sessionRefreshTimer = null
}
}
function startSessionRefresh() {
stopSessionRefresh()
sessionRefreshTimer = window.setInterval(() => {
if (!canRefreshSession()) return
void refreshAccessToken().catch(() => {
// Let the next foreground API request decide whether the session is gone.
})
}, SESSION_REFRESH_INTERVAL_MS)
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (!canRefreshSession()) return
void refreshAccessToken().catch(() => {
// Best-effort refresh after a sleeping tab wakes up.
})
})
}
function clearAuth() {
stopSessionRefresh()
user.value = null
setAccessToken(null)
disconnectAuthorEnrichmentSocket()
@@ -37,6 +71,7 @@ export function useAuth() {
const { accessToken } = await res.json()
setAccessToken(accessToken)
await me()
startSessionRefresh()
} catch {
// no valid session
} finally {
@@ -60,6 +95,7 @@ export function useAuth() {
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
startSessionRefresh()
if (data.user.isDefaultPassword) {
router.push('/')
@@ -95,6 +131,7 @@ export function useAuth() {
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
startSessionRefresh()
useSetupStatus().markSetupComplete()
router.push('/')
@@ -224,10 +224,9 @@ async function handleSetStatus(status: ReadStatus) {
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="!coverFailed"
v-if="coverLoaded && !coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-90"
aria-hidden="true"
loading="lazy"
/>
@@ -236,8 +235,8 @@ async function handleSetStatus(status: ReadStatus) {
v-if="!coverFailed"
:ref="onMainImgRef"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? (isMissing ? 'opacity-100 brightness-50' : 'opacity-100') : 'opacity-0'"
class="absolute inset-0 w-full h-full object-contain"
:class="isMissing ? 'brightness-50' : ''"
loading="lazy"
decoding="async"
:alt="book.title ?? ''"
@@ -137,19 +137,18 @@ function openAuthorBrowse() {
>
<!-- Blurred background fill for mismatched aspect ratios -->
<img
v-if="!coverFailed"
v-if="coverLoaded && !coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-50 transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-50"
aria-hidden="true"
loading="lazy"
/>
<img
v-if="!coverFailed"
:src="coverSrc"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 w-full h-full object-contain"
loading="lazy"
decoding="async"
:alt="book.title ?? ''"
@load="coverLoaded = true"
@error="coverFailed = true"
@@ -126,17 +126,15 @@ const scrollerStyle = computed(() => ({
}
.book-grid-cell--new {
animation: book-enter 0.35s ease-out both;
animation: book-enter 0.25s ease-out both;
}
@keyframes book-enter {
from {
opacity: 0;
transform: scale(0.92);
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
transform: translateY(0) scale(1);
}
}
</style>
+13 -9
View File
@@ -2,7 +2,7 @@ import type { RefreshResponse } from '@projectx/types'
let _accessToken: string | null = null
let _onAuthFailure: (() => void) | null = null
let _refreshPromise: Promise<void> | null = null
let _refreshPromise: Promise<string> | null = null
export function setAccessToken(token: string | null): void {
_accessToken = token
@@ -22,11 +22,21 @@ function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respons
return fetch(input, { ...init, headers, credentials: 'include' })
}
async function attemptRefresh(): Promise<void> {
async function attemptRefresh(): Promise<string> {
const res = await rawFetch('/api/v1/auth/refresh', { method: 'POST' })
if (!res.ok) throw new Error('refresh failed')
const data: RefreshResponse = await res.json()
_accessToken = data.accessToken
return data.accessToken
}
export async function refreshAccessToken(): Promise<string> {
if (!_refreshPromise) {
_refreshPromise = attemptRefresh().finally(() => {
_refreshPromise = null
})
}
return _refreshPromise
}
export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> {
@@ -41,14 +51,8 @@ export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRe
throw new Error('Session expired')
}
if (!_refreshPromise) {
_refreshPromise = attemptRefresh().finally(() => {
_refreshPromise = null
})
}
try {
await _refreshPromise
await refreshAccessToken()
} catch {
_onAuthFailure?.()
throw new Error('Session expired')
+11 -2
View File
@@ -23,18 +23,26 @@ services:
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required}
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-2048}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./books:/books
- ${BOOKS_HOST_PATH:-./books}:/books
- ./data/app:/data
- ./book-bucket:/data/book-bucket
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
@@ -57,6 +65,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
+1 -5
View File
@@ -16,11 +16,7 @@ SETUP_BOOTSTRAP_TOKEN=
# App data directory for covers, author images, user avatars, and cache.
# This path resolves relative to server/ (where the process runs).
# ../local/data points to <project-root>/local/data.
BOOKS_PATH=../local/data
# Book Bucket directory where files are dropped for auto-import.
# Defaults to {BOOKS_PATH}/book-bucket if unset.
# BOOK_BUCKET_PATH=../local/book-bucket
APP_DATA_PATH=../local/data
# File write scheduler and concurrency tuning (optional)
# FILE_WRITE_DEBOUNCE_MS=3000
+103 -3
View File
@@ -1,12 +1,112 @@
#!/bin/sh
set -e
log() {
echo "ProjectX startup: $*" >&2
}
ensure_dir() {
dir="$1"
if ! mkdir -p "$dir"; then
log "cannot create $dir. Grant the host directory write access, or set PUID/PGID to a user that can write there."
exit 1
fi
}
is_managed_data_path() {
case "$1" in
/data | /data/* | /tmp | /tmp/*) return 0 ;;
*) return 1 ;;
esac
}
fix_owner() {
dir="$1"
if [ "${PROJECTX_FIX_PERMISSIONS:-true}" != "true" ]; then
return 0
fi
if ! is_managed_data_path "$dir"; then
log "skipping ownership fix for $dir because it is outside /data."
return 0
fi
if ! chown -R "$APP_UID:$APP_GID" "$dir"; then
log "could not update ownership for $dir. Startup will continue only if UID:GID $APP_UID:$APP_GID can already write there."
fi
}
check_writable_as_user() {
dir="$1"
if ! su-exec "$APP_UID:$APP_GID" sh -c 'touch "$1/.projectx-permission-test" && rm -f "$1/.projectx-permission-test"' sh "$dir"; then
log "$dir is not writable by UID:GID $APP_UID:$APP_GID."
log "Set PUID/PGID to a NAS user with write access, or fix the host folder ownership/ACLs."
exit 1
fi
}
check_writable_current_user() {
dir="$1"
if ! touch "$dir/.projectx-permission-test" || ! rm -f "$dir/.projectx-permission-test"; then
log "$dir is not writable by the current container user $(id -u):$(id -g)."
log "Remove the compose user override, set PUID/PGID, or fix the host folder ownership/ACLs."
exit 1
fi
}
if [ -z "$DATABASE_URL" ]; then
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}"
fi
export BOOKS_PATH="${BOOKS_PATH:-/data}"
export BOOK_BUCKET_PATH="${BOOK_BUCKET_PATH:-/book-bucket}"
export APP_DATA_PATH="${APP_DATA_PATH:-/data}"
book_bucket_path="$APP_DATA_PATH/book-bucket"
export HOME="${HOME:-/tmp}"
APP_UID="${PUID:-1000}"
APP_GID="${PGID:-1000}"
NODE_MAX_OLD_SPACE_SIZE="${NODE_MAX_OLD_SPACE_SIZE:-2048}"
case "$APP_UID" in
'' | *[!0-9]*)
log "PUID must be a numeric UID, got '$APP_UID'."
exit 1
;;
esac
case "$APP_GID" in
'' | *[!0-9]*)
log "PGID must be a numeric GID, got '$APP_GID'."
exit 1
;;
esac
case "$NODE_MAX_OLD_SPACE_SIZE" in
'' | *[!0-9]*)
log "NODE_MAX_OLD_SPACE_SIZE must be a numeric MB value, got '$NODE_MAX_OLD_SPACE_SIZE'."
exit 1
;;
esac
if [ "$NODE_MAX_OLD_SPACE_SIZE" -lt 1 ]; then
log "NODE_MAX_OLD_SPACE_SIZE must be greater than 0."
exit 1
fi
export NODE_MAX_OLD_SPACE_SIZE
ensure_dir "$APP_DATA_PATH"
ensure_dir "$APP_DATA_PATH/covers"
ensure_dir "$book_bucket_path"
if [ "$(id -u)" = "0" ]; then
fix_owner "$APP_DATA_PATH"
fix_owner "$book_bucket_path"
check_writable_as_user "$APP_DATA_PATH"
check_writable_as_user "$APP_DATA_PATH/covers"
check_writable_as_user "$book_bucket_path"
exec su-exec "$APP_UID:$APP_GID" sh -c 'node dist/scripts/migrate.js && exec node --max-old-space-size="$NODE_MAX_OLD_SPACE_SIZE" dist/main.js'
fi
check_writable_current_user "$APP_DATA_PATH"
check_writable_current_user "$APP_DATA_PATH/covers"
check_writable_current_user "$book_bucket_path"
node dist/scripts/migrate.js
exec node --max-old-space-size=512 dist/main.js
exec node --max-old-space-size="$NODE_MAX_OLD_SPACE_SIZE" dist/main.js
+4 -4
View File
@@ -4,12 +4,12 @@ export const COVER_CUSTOM_FILE_PREFIX = 'cover_custom.';
export const COVER_EXTRACTED_FILE_PREFIX = 'cover_extracted.';
export const COVER_THUMBNAIL_FILE_NAME = 'thumbnail.jpg';
export function bookCoverDirPath(booksPath: string, bookId: number): string {
return join(booksPath, 'covers', String(bookId));
export function bookCoverDirPath(appDataPath: string, bookId: number): string {
return join(appDataPath, 'covers', String(bookId));
}
export function bookThumbnailPath(booksPath: string, bookId: number): string {
return join(bookCoverDirPath(booksPath, bookId), COVER_THUMBNAIL_FILE_NAME);
export function bookThumbnailPath(appDataPath: string, bookId: number): string {
return join(bookCoverDirPath(appDataPath, bookId), COVER_THUMBNAIL_FILE_NAME);
}
export function isCustomBookCoverFileName(fileName: string): boolean {
+1 -2
View File
@@ -17,8 +17,7 @@ export const authConfig = registerAs('auth', () => ({
}));
export const storageConfig = registerAs('storage', () => ({
booksPath: process.env.BOOKS_PATH ?? '/data',
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH ?? '/data',
}));
export const fileWriteConfig = registerAs('fileWrite', () => ({
+2 -2
View File
@@ -13,12 +13,12 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
BOOKS_PATH: z.string().default('/data'),
BOOK_BUCKET_PATH: z.string().optional(),
APP_DATA_PATH: z.string().default('/data'),
FILE_WRITE_DEBOUNCE_MS: z.coerce.number().int().positive().optional(),
FILE_WRITE_MAX_CONCURRENT_WRITES: z.coerce.number().int().positive().optional(),
CLIENT_URL: z.string().url().optional(),
APP_URL: z.string().url().default('http://localhost:5173'),
TRUST_PROXY: z.string().optional(),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
});
+23 -2
View File
@@ -14,9 +14,24 @@ import fastifyHelmet from '@fastify/helmet';
import fastifyCompress from '@fastify/compress';
const MAX_COVER_BYTES = 20 * 1024 * 1024;
const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal';
function parseTrustProxy(value: string | undefined) {
const raw = value?.trim();
if (!raw) return DEFAULT_TRUST_PROXY;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
const hopCount = Number(raw);
if (Number.isInteger(hopCount) && hopCount >= 0) return hopCount;
return raw;
}
async function bootstrap() {
const adapter = new FastifyAdapter({ logger: false });
const adapter = new FastifyAdapter({ logger: false, trustProxy: parseTrustProxy(process.env.TRUST_PROXY) });
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { bufferLogs: true });
app.useLogger(app.get(Logger));
@@ -64,6 +79,7 @@ async function bootstrap() {
app.useGlobalFilters(new GlobalExceptionFilter());
await app.register(fastifyHelmet as never, {
crossOriginOpenerPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
@@ -75,6 +91,7 @@ async function bootstrap() {
objectSrc: ["'none'"],
frameSrc: ["'self'", 'blob:'],
frameAncestors: ["'none'"],
upgradeInsecureRequests: null,
},
},
});
@@ -114,4 +131,8 @@ async function bootstrap() {
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
void bootstrap();
bootstrap().catch((err: unknown) => {
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
process.stderr.write(`ProjectX startup failed:\n${message}\n`);
process.exit(1);
});
+48 -2
View File
@@ -1,4 +1,6 @@
import { BadRequestException, ConflictException, ForbiddenException, ServiceUnavailableException, UnauthorizedException } from '@nestjs/common';
import fastifyCookie from '@fastify/cookie';
import Fastify from 'fastify';
vi.mock('bcryptjs', () => ({
hash: vi.fn((value: string) => Promise.resolve(`mock-hash:${value}`)),
@@ -37,8 +39,8 @@ function makeReply() {
} as never;
}
function makeRequest(cookies: Record<string, string> = {}) {
return { cookies, headers: {} } as never;
function makeRequest(cookies: Record<string, string> = {}, headers: Record<string, string | string[]> = {}) {
return { cookies, headers, raw: { socket: {} } } as never;
}
function makeFullUser(overrides?: Partial<Record<string, unknown>>) {
@@ -113,6 +115,30 @@ function makeService(dbOverrides?: Record<string, unknown>) {
}
describe('AuthService', () => {
describe('cookie transport security', () => {
it('omits Secure on direct HTTP and adds Secure for trusted-proxy HTTPS', async () => {
const app = Fastify({ trustProxy: 'loopback,linklocal,uniquelocal' });
await app.register(fastifyCookie);
app.get('/cookie', (_request, reply) => {
reply.setCookie('probe', '1', { path: '/', secure: 'auto' }).send({ ok: true });
});
try {
const directHttp = await app.inject({ method: 'GET', url: '/cookie' });
expect(String(directHttp.headers['set-cookie'])).not.toContain('Secure');
const proxiedHttps = await app.inject({
method: 'GET',
url: '/cookie',
headers: { 'x-forwarded-proto': 'https' },
});
expect(String(proxiedHttps.headers['set-cookie'])).toContain('Secure');
} finally {
await app.close();
}
});
});
describe('setupStatus', () => {
it('returns needsSetup=true when there are no users', async () => {
const { service, db } = makeService();
@@ -256,6 +282,26 @@ describe('AuthService', () => {
await expect(service.login({ username: 'jdoe', password: 'wrongpass' }, makeReply())).rejects.toThrow(UnauthorizedException);
});
it('sets auth cookies with automatic Secure mode', async () => {
const { service, userService } = makeService();
const reply = makeReply();
userService.findByUsername.mockResolvedValue({ id: 1, active: true, passwordHash: 'mock-hash:pass', tokenVersion: 1, username: 'jdoe' });
userService.findByIdWithPermissions.mockResolvedValue(makeFullUser());
await service.login({ username: 'jdoe', password: 'pass' }, reply);
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'access_token',
'signed-jwt',
expect.objectContaining({ path: '/api', secure: 'auto' }),
);
expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith(
'refresh_token',
expect.any(String),
expect.objectContaining({ path: '/api/v1/auth', secure: 'auto' }),
);
});
});
describe('buildUserResponse', () => {
+4 -4
View File
@@ -535,7 +535,7 @@ export class AuthService {
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: ttlSeconds,
secure: this.config.get('app.nodeEnv') === 'production',
secure: 'auto',
});
}
@@ -545,7 +545,7 @@ export class AuthService {
sameSite: 'strict',
path: '/api/v1/auth',
maxAge: 0,
secure: this.config.get('app.nodeEnv') === 'production',
secure: 'auto',
});
}
@@ -556,7 +556,7 @@ export class AuthService {
sameSite: 'lax',
path: '/api',
maxAge: ttlSeconds,
secure: this.config.get('app.nodeEnv') === 'production',
secure: 'auto',
});
}
@@ -566,7 +566,7 @@ export class AuthService {
sameSite: 'lax',
path: '/api',
maxAge: 0,
secure: this.config.get('app.nodeEnv') === 'production',
secure: 'auto',
});
}
}
@@ -36,10 +36,10 @@ export class AuthorImageStorageError extends Error {
@Injectable()
export class AuthorImageStorageService {
private readonly logger = new Logger(AuthorImageStorageService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(private readonly config: ConfigService) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async saveFromUrl(authorId: number, rawUrl: string): Promise<boolean> {
@@ -113,7 +113,7 @@ export class AuthorImageStorageService {
}
private authorDir(authorId: number): string {
return join(this.booksPath, 'authors', String(authorId));
return join(this.appDataPath, 'authors', String(authorId));
}
private async isReadable(path: string): Promise<boolean> {
@@ -13,8 +13,7 @@ const mockedStat = vi.mocked(stat);
function makeService() {
const config = {
get: vi.fn((key: string) => {
if (key === 'storage.booksPath') return '/books';
if (key === 'storage.bookBucketPath') return undefined;
if (key === 'storage.appDataPath') return '/books';
return undefined;
}),
};
@@ -30,8 +30,8 @@ export class BookBucketIngestService {
private readonly metadataFetchPipeline: MetadataFetchPipeline,
private readonly gateway: BookBucketGateway,
) {
const booksPath = this.config.get<string>('storage.booksPath')!;
this.bookBucketPath = this.config.get<string>('storage.bookBucketPath') ?? join(booksPath, 'book-bucket');
const appDataPath = this.config.get<string>('storage.appDataPath')!;
this.bookBucketPath = join(appDataPath, 'book-bucket');
}
async ingestUpload(rawFilename: string, fileStream: Readable): Promise<number> {
@@ -29,8 +29,8 @@ export class BookBucketWatcherService implements OnApplicationBootstrap, OnModul
private readonly repo: BookBucketRepository,
private readonly gateway: BookBucketGateway,
) {
const booksPath = this.config.get<string>('storage.booksPath')!;
this.bookBucketPath = this.config.get<string>('storage.bookBucketPath') ?? join(booksPath, 'book-bucket');
const appDataPath = this.config.get<string>('storage.appDataPath')!;
this.bookBucketPath = join(appDataPath, 'book-bucket');
}
async onApplicationBootstrap(): Promise<void> {
+1 -1
View File
@@ -84,7 +84,7 @@ function makeService() {
runWithSources: vi.fn(),
};
const config = {
get: vi.fn().mockImplementation((key: string) => (key === 'storage.booksPath' ? '/tmp/books' : undefined)),
get: vi.fn().mockImplementation((key: string) => (key === 'storage.appDataPath' ? '/tmp/books' : undefined)),
};
const appSettings = {
getDownloadPattern: vi.fn().mockResolvedValue('{originalFilename}'),
+5 -5
View File
@@ -48,7 +48,7 @@ export type MetadataUpdateFailpoint = (typeof METADATA_UPDATE_FAILPOINTS)[number
@Injectable()
export class BookService {
private readonly logger = new Logger(BookService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
private embeddingRun: Promise<void> | null = null;
private metadataUpdateFailpoint: MetadataUpdateFailpoint | null = null;
@@ -68,7 +68,7 @@ export class BookService {
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly fileWriteService: FileWriteService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
private isSuperuser(user: RequestUser): boolean {
@@ -186,7 +186,7 @@ export class BookService {
async getCoverPath(id: number, user: RequestUser): Promise<string | null> {
const event = 'book.get_cover_path';
await this.verifyBookAccess(id, user);
const dir = bookCoverDirPath(this.booksPath, id);
const dir = bookCoverDirPath(this.appDataPath, id);
try {
const files = await readdir(dir);
const cover = findPreferredBookCoverFileName(files);
@@ -205,7 +205,7 @@ export class BookService {
async getThumbnailPath(id: number, user: RequestUser): Promise<string | null> {
const event = 'book.get_thumbnail_path';
await this.verifyBookAccess(id, user);
const path = bookThumbnailPath(this.booksPath, id);
const path = bookThumbnailPath(this.appDataPath, id);
try {
await access(path);
return path;
@@ -358,7 +358,7 @@ export class BookService {
await this.bookRepo.deleteByIds(bookIds);
const deleteTargets = [
...rows.map((row) => ({
path: join(this.booksPath, 'covers', String(row.id)),
path: join(this.appDataPath, 'covers', String(row.id)),
options: { recursive: true, force: true },
kind: 'coverDir' as const,
})),
+8 -8
View File
@@ -41,7 +41,7 @@ const ITUNES_INTERLEAVE_LIMIT = 5;
@Injectable()
export class CoverService {
private readonly logger = new Logger(CoverService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(
@Inject(DB) private readonly db: Db,
@@ -51,7 +51,7 @@ export class CoverService {
private readonly config: ConfigService,
private readonly providerRegistry: CoverProviderRegistry,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async searchCovers(params: CoverSearchParams & { provider?: string }): Promise<CoverSearchResult[]> {
@@ -152,12 +152,12 @@ export class CoverService {
try {
await this.verifyAccess(bookId, user);
await this.bookMetadataLockService.assertFieldsUnlocked(bookId, ['cover']);
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
await this.deleteFilesByPrefix(dir, COVER_CUSTOM_FILE_PREFIX);
const extractedPath = await this.findExtractedCover(bookId);
if (!extractedPath) {
await this.removeFileIfPresent(bookThumbnailPath(this.booksPath, bookId));
await this.removeFileIfPresent(bookThumbnailPath(this.appDataPath, bookId));
await this.setCoverSource(bookId, null);
this.logger.log(
`[cover.delete] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} coverSource=null - cover deletion completed`,
@@ -167,7 +167,7 @@ export class CoverService {
const bytes = await readFile(extractedPath);
const thumb = await generateThumbnail(bytes);
await writeFile(bookThumbnailPath(this.booksPath, bookId), thumb);
await writeFile(bookThumbnailPath(this.appDataPath, bookId), thumb);
await this.setCoverSource(bookId, 'extracted');
this.logger.log(
`[cover.delete] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} coverSource=extracted - cover deletion completed`,
@@ -230,18 +230,18 @@ export class CoverService {
}
private async saveCustomCover(bookId: number, buffer: Buffer): Promise<void> {
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
await mkdir(dir, { recursive: true });
await this.deleteFilesByPrefix(dir, COVER_CUSTOM_FILE_PREFIX);
const ext = imageExt(buffer);
await writeFile(join(dir, `${COVER_CUSTOM_FILE_PREFIX}${ext}`), buffer);
const thumb = await generateThumbnail(buffer);
await writeFile(bookThumbnailPath(this.booksPath, bookId), thumb);
await writeFile(bookThumbnailPath(this.appDataPath, bookId), thumb);
}
private async findExtractedCover(bookId: number): Promise<string | null> {
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
const files = await this.readDirIfExists(dir);
const found = findExtractedBookCoverFileName(files);
return found ? join(dir, found) : null;
@@ -47,7 +47,7 @@ describe('FileWriteService', () => {
withLock: vi.fn().mockImplementation(async (_path: string, fn: () => Promise<unknown>) => fn()),
};
const config = {
get: vi.fn().mockImplementation((key: string) => (key === 'storage.booksPath' ? '/books' : configValues[key])),
get: vi.fn().mockImplementation((key: string) => (key === 'storage.appDataPath' ? '/books' : configValues[key])),
} as unknown as ConfigService;
const service = new FileWriteService(fileWriteRepo as never, registry as never, lockService as never, config);
@@ -21,7 +21,7 @@ const DEFAULT_MAX_CONCURRENT_WRITES = 2;
@Injectable()
export class FileWriteService implements OnModuleDestroy {
private readonly logger = new Logger(FileWriteService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
private readonly debounceMs: number;
private readonly maxConcurrentWrites: number;
private readonly debounceMap = new Map<number, NodeJS.Timeout>();
@@ -35,7 +35,7 @@ export class FileWriteService implements OnModuleDestroy {
private readonly lockService: FileLockService,
private readonly config: ConfigService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
this.debounceMs = resolvePositiveInteger(this.config.get('fileWrite.debounceMs'), DEFAULT_WRITE_DEBOUNCE_MS);
this.maxConcurrentWrites = resolvePositiveInteger(this.config.get('fileWrite.maxConcurrentWrites'), DEFAULT_MAX_CONCURRENT_WRITES);
}
@@ -202,7 +202,7 @@ export class FileWriteService implements OnModuleDestroy {
private async loadCoverBytes(bookId: number): Promise<Buffer | null> {
const startedAt = Date.now();
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
try {
const files = await readdir(dir);
const cover = findPreferredBookCoverFileName(files);
@@ -9,17 +9,17 @@ const KEPUBIFY_BASE_URL = 'https://github.com/booklore-app/booklore-tools/raw/ma
@Injectable()
export class KepubifyBinaryService {
private readonly logger = new Logger(KepubifyBinaryService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
private cachedBinaryPath: string | null = null;
constructor(private readonly config: ConfigService) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async getBinaryPath(): Promise<string> {
if (this.cachedBinaryPath) return this.cachedBinaryPath;
const binaryName = this.detectBinaryName();
const toolsDir = join(this.booksPath, '.tools', 'kepubify');
const toolsDir = join(this.appDataPath, '.tools', 'kepubify');
const binaryPath = join(toolsDir, binaryName);
await this.ensureBinary(binaryPath, binaryName, toolsDir);
this.cachedBinaryPath = binaryPath;
@@ -28,7 +28,7 @@ const MIME: Record<string, string> = {
@Injectable()
export class KoboDownloadService {
private readonly logger = new Logger(KoboDownloadService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
private readonly kepubCachePath: string;
constructor(
@@ -38,8 +38,8 @@ export class KoboDownloadService {
private readonly settingsService: KoboSettingsService,
private readonly bookAccessService: KoboBookAccessService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.kepubCachePath = join(this.booksPath, '.kepub-cache');
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
this.kepubCachePath = join(this.appDataPath, '.kepub-cache');
}
async streamBook(userId: number, bookId: number, reply: FastifyReply) {
@@ -9,19 +9,19 @@ import { KoboBookAccessService } from './kobo-book-access.service';
@Injectable()
export class KoboThumbnailService {
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(
private readonly config: ConfigService,
private readonly bookAccessService: KoboBookAccessService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async serveThumbnail(userId: number, bookId: number, ifNoneMatch: string | undefined, reply: FastifyReply) {
await this.bookAccessService.assertBookAccessible(userId, bookId);
const thumbnailPath = bookThumbnailPath(this.booksPath, bookId);
const thumbnailPath = bookThumbnailPath(this.appDataPath, bookId);
try {
const { mtimeMs } = await stat(thumbnailPath);
const etag = `"${Math.floor(mtimeMs)}"`;
@@ -39,7 +39,7 @@ export class KoboThumbnailService {
}
async serveCover(bookId: number, ifNoneMatch: string | undefined, reply: FastifyReply) {
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
try {
const files = await readdir(dir);
const cover = findPreferredBookCoverFileName(files);
@@ -42,7 +42,7 @@ interface LibraryMetadataWriteSummary {
@Injectable()
export class LibraryService {
private readonly logger = new Logger(LibraryService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(
private readonly libraryRepo: LibraryRepository,
@@ -51,7 +51,7 @@ export class LibraryService {
private readonly fileWatcherService: FileWatcherService,
private readonly fileWriteService: FileWriteService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async verifyUserAccess(userId: number, libraryId: number, isSuperuser: boolean): Promise<void> {
@@ -347,7 +347,7 @@ export class LibraryService {
const chunk = bookIds.slice(index, index + concurrency);
await Promise.all(
chunk.map(async (bookId) => {
const coverDir = join(this.booksPath, 'covers', String(bookId));
const coverDir = join(this.appDataPath, 'covers', String(bookId));
try {
await rm(coverDir, { recursive: true, force: true });
deletedCount++;
+2 -2
View File
@@ -50,6 +50,6 @@ export async function extractCover(absolutePath: string, format: string): Promis
}
/** Resolve the cover directory for a book on disk. */
export function coverDirPath(booksPath: string, bookId: number): string {
return bookCoverDirPath(booksPath, bookId);
export function coverDirPath(appDataPath: string, bookId: number): string {
return bookCoverDirPath(appDataPath, bookId);
}
@@ -48,7 +48,7 @@ const EXTRACTED_COVER_SOURCE = 'extracted';
@Injectable()
export class MetadataService {
private readonly logger = new Logger(MetadataService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
private readonly extractorMap: Map<string, FormatExtractor>;
constructor(
@@ -61,7 +61,7 @@ export class MetadataService {
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly metadataEvents?: MetadataEventsService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
const audio = new AudioFormatExtractor();
const mobi = new MobiFormatExtractor();
const epub = new EpubFormatExtractor();
@@ -540,7 +540,7 @@ export class MetadataService {
private async persistCover(bookId: number, bytes: Buffer, overwrite: boolean): Promise<void> {
if (await this.bookMetadataLockService.isFieldLocked(bookId, 'cover')) return;
const ext = imageExt(bytes);
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
await mkdir(dir, { recursive: true });
const files = await readdir(dir).catch(() => [] as string[]);
@@ -553,7 +553,7 @@ export class MetadataService {
if (!hasCustom) {
const thumbnail = await generateThumbnail(bytes);
await writeFile(bookThumbnailPath(this.booksPath, bookId), thumbnail);
await writeFile(bookThumbnailPath(this.appDataPath, bookId), thumbnail);
}
if (overwrite) {
@@ -27,7 +27,7 @@ export class CoverImporter {
async import(
runId: number,
planned: PlannerResult,
booksPath: string,
appDataPath: string,
sourceMediaRootPath: string | null,
ensureRunning: RunStateCheck,
): Promise<void> {
@@ -47,7 +47,7 @@ export class CoverImporter {
await ensureRunning();
const batch = matches.slice(i, i + COVER_CONCURRENCY);
const results = await Promise.allSettled(batch.map((match) => this.processSingleMatch(runId, match, booksPath, sourceMediaRootPath)));
const results = await Promise.allSettled(batch.map((match) => this.processSingleMatch(runId, match, appDataPath, sourceMediaRootPath)));
for (let j = 0; j < results.length; j++) {
const result = results[j];
@@ -73,7 +73,7 @@ export class CoverImporter {
private async processSingleMatch(
runId: number,
match: { sourceBookId: string; targetBookId: number },
booksPath: string,
appDataPath: string,
sourceMediaRootPath: string,
): Promise<'imported' | 'unresolved' | 'failed'> {
const sourceImageDir = join(sourceMediaRootPath, 'images', match.sourceBookId);
@@ -83,7 +83,7 @@ export class CoverImporter {
if (!coverBytes) return 'unresolved';
try {
await this.importSingleCover(match.targetBookId, booksPath, coverBytes, sourceThumbnailPath);
await this.importSingleCover(match.targetBookId, appDataPath, coverBytes, sourceThumbnailPath);
return 'imported';
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -92,8 +92,8 @@ export class CoverImporter {
}
}
private async importSingleCover(targetBookId: number, booksPath: string, coverBytes: Buffer, sourceThumbnailPath: string): Promise<void> {
const targetCoverDir = bookCoverDirPath(booksPath, targetBookId);
private async importSingleCover(targetBookId: number, appDataPath: string, coverBytes: Buffer, sourceThumbnailPath: string): Promise<void> {
const targetCoverDir = bookCoverDirPath(appDataPath, targetBookId);
await mkdir(targetCoverDir, { recursive: true });
await this.deleteFilesByPrefix(targetCoverDir, COVER_CUSTOM_FILE_PREFIX);
@@ -20,7 +20,7 @@ const STAGE_ORDER = ['shared_overlays', 'book_covers', 'user_state'] as const;
export class MigrationExecutorService {
private readonly logger = new Logger(MigrationExecutorService.name);
private readonly running = new Set<number>();
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(
private readonly repo: MigrationRepository,
@@ -31,7 +31,7 @@ export class MigrationExecutorService {
private readonly encryption: MigrationEncryptionService,
private readonly progressGateway: MigrationProgressGateway,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
start(runId: number): void {
@@ -94,7 +94,7 @@ export class MigrationExecutorService {
await this.emitRunProgress(runId, 'running', 'shared_overlays');
await this.executeStage(runId, 'book_covers', completedStages, async (ensureRunning) =>
this.covers.import(runId, planned, this.booksPath, sourceMediaRootPath, ensureRunning),
this.covers.import(runId, planned, this.appDataPath, sourceMediaRootPath, ensureRunning),
);
await this.emitRunProgress(runId, 'running', 'book_covers');
+4 -4
View File
@@ -31,14 +31,14 @@ import { OpdsService } from './opds.service';
@Public()
@UseGuards(OpdsEnabledGuard, OpdsAuthGuard)
export class OpdsController {
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(
private readonly opdsService: OpdsService,
private readonly opdsBookService: OpdsBookService,
private readonly config: ConfigService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@Get()
@@ -194,7 +194,7 @@ export class OpdsController {
@Headers('if-none-match') ifNoneMatch?: string,
) {
await this.opdsBookService.validateBookAccess(bookId, user.userId, user.isSuperuser);
const dir = bookCoverDirPath(this.booksPath, bookId);
const dir = bookCoverDirPath(this.appDataPath, bookId);
try {
const files = await readdir(dir);
const cover = findPreferredBookCoverFileName(files);
@@ -224,7 +224,7 @@ export class OpdsController {
@Headers('if-none-match') ifNoneMatch?: string,
) {
await this.opdsBookService.validateBookAccess(bookId, user.userId, user.isSuperuser);
const thumbnailPath = bookThumbnailPath(this.booksPath, bookId);
const thumbnailPath = bookThumbnailPath(this.appDataPath, bookId);
try {
const { mtimeMs } = await stat(thumbnailPath);
const etag = `"${Math.floor(mtimeMs)}"`;
@@ -7,7 +7,7 @@ import { FileEventProcessorService } from './file-event-processor.service';
import { ScanGateway } from './scan.gateway';
import { ScannerService } from './scanner.service';
function makeService() {
function makeService(db: any = {}) {
const processor = {
handleUnlink: vi.fn().mockResolvedValue({ type: 'noop' }),
handleUnlinkDir: vi.fn().mockResolvedValue({ type: 'noop' }),
@@ -27,7 +27,6 @@ function makeService() {
isScanRunning: vi.fn().mockReturnValue(false),
} as unknown as ScannerService;
const db = {} as any;
const service = new FileWatcherService(db, processor, gateway, scannerService);
return { service, processor, gateway, scannerService };
}
@@ -35,6 +34,26 @@ function makeService() {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
// ── bootstrap resilience ─────────────────────────────────────────────────────
describe('onApplicationBootstrap()', () => {
it('continues startup when a library watcher fails to bind', async () => {
const db = {
select: vi
.fn()
.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 42 }]) }) })
.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ path: '/books' }]) }) }),
};
const { service } = makeService(db);
const startWatcher = vi.spyOn(service, 'startWatcher').mockRejectedValue(new Error('Bad file descriptor'));
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
expect(startWatcher).toHaveBeenCalledWith(42, ['/books']);
await service.onModuleDestroy();
});
});
// ── process() routing ─────────────────────────────────────────────────────────
describe('process()', () => {
@@ -47,12 +47,22 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
this.logger.log(`[${event}] [start] - watcher bootstrap started`);
try {
const watchedLibraries = await this.db.select().from(libraries).where(eq(libraries.watch, true));
let failedWatcherCount = 0;
for (const lib of watchedLibraries) {
const folders = await this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, lib.id));
try {
await this.startWatcher(
lib.id,
folders.map((f) => f.path),
);
} catch (err) {
failedWatcherCount += 1;
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
this.logger.warn(
`[${event}] [skip] libraryId=${lib.id} pathCount=${folders.length} errorClass=${errorClass} error="${errorMessage}" - watcher disabled for library during startup`,
);
}
}
this.reconcileTimer = setInterval(() => {
@@ -63,7 +73,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
});
}, RECONCILE_MS);
this.logger.log(
`[${event}] [end] durationMs=${Date.now() - startedAt} watchedLibraryCount=${watchedLibraries.length} - watcher bootstrap completed`,
`[${event}] [end] durationMs=${Date.now() - startedAt} watchedLibraryCount=${watchedLibraries.length} failedWatcherCount=${failedWatcherCount} - watcher bootstrap completed`,
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
@@ -140,6 +150,8 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
const event = 'scanner.watcher.start';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] libraryId=${libraryId} pathCount=${paths.length} - watcher start requested`);
const subs: AsyncSubscription[] = [];
let currentPath: string | null = null;
try {
await this.stopWatcher(libraryId);
if (paths.length === 0) {
@@ -148,9 +160,9 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
}
const { subscribe } = await import('@parcel/watcher');
const subs: AsyncSubscription[] = [];
for (const path of paths) {
currentPath = path;
const sub = await subscribe(path, (err, events) => {
if (err) {
this.logger.warn(
@@ -169,14 +181,23 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
}
this.subscriptions.set(libraryId, subs);
currentPath = null;
this.logger.log(
`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} pathCount=${paths.length} - watcher start completed`,
);
} catch (err) {
for (const sub of subs) {
try {
await sub.unsubscribe();
} catch {
// best-effort cleanup after a partial watcher startup
}
}
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
const pathPart = currentPath ? ` path="${currentPath.replace(/"/g, '\\"')}"` : '';
this.logger.warn(
`[${event}] [fail] libraryId=${libraryId} pathCount=${paths.length} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - watcher start failed`,
`[${event}] [fail] libraryId=${libraryId}${pathPart} pathCount=${paths.length} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - watcher start failed`,
);
throw err;
}
@@ -7,10 +7,10 @@ import { join } from 'path';
@Injectable()
export class UserAvatarStorageService {
private readonly logger = new Logger(UserAvatarStorageService.name);
private readonly booksPath: string;
private readonly appDataPath: string;
constructor(private readonly config: ConfigService) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
async saveAvatar(userId: number, bytes: Buffer): Promise<void> {
@@ -38,7 +38,7 @@ export class UserAvatarStorageService {
}
private avatarDir(userId: number): string {
return join(this.booksPath, 'users', String(userId));
return join(this.appDataPath, 'users', String(userId));
}
private avatarPath(userId: number): string {
@@ -35,8 +35,7 @@ const ADMIN_SETUP_DTO = {
};
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
}
export interface AuthorizationMatrixE2EContext {
@@ -102,12 +101,10 @@ export function authHeader(token: string): Record<string, string> {
export async function createAuthorizationMatrixE2EContext(): Promise<AuthorizationMatrixE2EContext> {
const fixture = await createAuthorizationFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const metadataMock = makeMetadataNoopMock();
const moduleFixture = await Test.createTestingModule({
@@ -523,9 +520,6 @@ async function setSettingValue(db: Db, key: string, value: string): Promise<void
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}
@@ -26,8 +26,7 @@ const ADMIN_SETUP_DTO = {
};
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
}
export interface BookBucketE2EContext {
@@ -80,12 +79,10 @@ export function authHeader(token: string): Record<string, string> {
export async function createBookBucketE2EContext(): Promise<BookBucketE2EContext> {
const fixture = await createBookBucketFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -390,9 +387,6 @@ async function setSetting(db: Db, key: string, value: string): Promise<void> {
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}
+5 -11
View File
@@ -32,8 +32,7 @@ function sleep(ms: number): Promise<void> {
}
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
appUrl: string | undefined;
}
@@ -329,13 +328,11 @@ export function authHeader(token: string): Record<string, string> {
export async function createEmailE2EContext(): Promise<EmailE2EContext> {
const fixture = await createEmailFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
appUrl: process.env.APP_URL,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
process.env.APP_URL = 'http://localhost:4173';
const smtpSink = new InProcessSmtpSink();
@@ -745,11 +742,8 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
if (snapshot.appUrl === undefined) delete process.env.APP_URL;
else process.env.APP_URL = snapshot.appUrl;
@@ -54,7 +54,7 @@ const ADMIN_SETUP_DTO = {
const MAX_MULTIPART_BYTES = 20 * 1024 * 1024;
interface EnvSnapshot {
booksPath: string | undefined;
appDataPath: string | undefined;
}
interface StaticCoverServer {
@@ -119,10 +119,10 @@ export function authHeader(token: string): Record<string, string> {
export async function createMetadataLockE2EContext(): Promise<MetadataLockE2EContext> {
const fixture = await createMetadataLockFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const coverServer = await startStaticCoverServer();
@@ -506,8 +506,8 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}
function sleep(ms: number): Promise<void> {
@@ -26,7 +26,7 @@ const ADMIN_SETUP_DTO = {
};
interface EnvSnapshot {
booksPath: string | undefined;
appDataPath: string | undefined;
fileWriteDebounceMs: string | undefined;
fileWriteMaxConcurrentWrites: string | undefined;
}
@@ -78,12 +78,12 @@ export function authHeader(token: string): Record<string, string> {
export async function createMetadataWriteE2EContext(): Promise<MetadataWriteE2EContext> {
const fixture = await createMetadataWriteFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
appDataPath: process.env.APP_DATA_PATH,
fileWriteDebounceMs: process.env.FILE_WRITE_DEBOUNCE_MS,
fileWriteMaxConcurrentWrites: process.env.FILE_WRITE_MAX_CONCURRENT_WRITES,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.APP_DATA_PATH = fixture.booksPath;
process.env.FILE_WRITE_DEBOUNCE_MS = '25';
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '1';
@@ -428,8 +428,8 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
if (snapshot.fileWriteDebounceMs === undefined) delete process.env.FILE_WRITE_DEBOUNCE_MS;
else process.env.FILE_WRITE_DEBOUNCE_MS = snapshot.fileWriteDebounceMs;
@@ -16,7 +16,7 @@ const TEST_USER_PREFIX = 'migration-booklore-';
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
interface EnvSnapshot {
booksPath: string | undefined;
appDataPath: string | undefined;
migrationEncryptionKey: string | undefined;
}
@@ -56,13 +56,13 @@ export async function createMigrationBookloreE2EContext(): Promise<MigrationBook
const booksPath = join(fixtureRoot, 'books');
const sourceMediaRoot = join(fixtureRoot, 'booklore-media');
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
appDataPath: process.env.APP_DATA_PATH,
migrationEncryptionKey: process.env.MIGRATION_ENCRYPTION_KEY,
};
await mkdir(booksPath, { recursive: true });
await mkdir(sourceMediaRoot, { recursive: true });
process.env.BOOKS_PATH = booksPath;
process.env.APP_DATA_PATH = booksPath;
process.env.MIGRATION_ENCRYPTION_KEY = ENCRYPTION_KEY;
let base: E2EContext | null = null;
@@ -518,7 +518,7 @@ async function resetDirectory(path: string): Promise<void> {
}
function restoreEnv(snapshot: EnvSnapshot): void {
process.env.BOOKS_PATH = snapshot.booksPath;
process.env.APP_DATA_PATH = snapshot.appDataPath;
process.env.MIGRATION_ENCRYPTION_KEY = snapshot.migrationEncryptionKey;
}
+5 -11
View File
@@ -26,8 +26,7 @@ const ADMIN_SETUP_DTO = {
};
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
}
export interface OpdsE2EContext {
@@ -72,12 +71,10 @@ export function basicAuth(username: string, password: string): string {
export async function createOpdsE2EContext(): Promise<OpdsE2EContext> {
const fixture = await createOpdsFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -397,11 +394,8 @@ async function setSettingValue(db: Db, key: string, value: string): Promise<void
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}
export { seedLibrary };
@@ -28,8 +28,7 @@ const ADMIN_SETUP_DTO = {
};
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
}
export interface ReaderStateIsolationE2EContext {
@@ -69,12 +68,10 @@ export function authHeader(token: string): Record<string, string> {
export async function createReaderStateIsolationE2EContext(): Promise<ReaderStateIsolationE2EContext> {
const fixture = await createReaderStateIsolationFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -324,11 +321,8 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}
export { seedLibrary };
@@ -30,8 +30,7 @@ const MAX_MULTIPART_BYTES = 20 * 1024 * 1024;
const BCRYPT_TEST_ROUNDS = 4;
interface EnvSnapshot {
booksPath: string | undefined;
bookBucketPath: string | undefined;
appDataPath: string | undefined;
}
export interface UsersAdminLifecycleE2EContext {
@@ -68,12 +67,10 @@ export function authHeader(token: string): Record<string, string> {
export async function createUsersAdminLifecycleE2EContext(): Promise<UsersAdminLifecycleE2EContext> {
const fixture = await createUsersAdminLifecycleFixtureRoot();
const envSnapshot: EnvSnapshot = {
booksPath: process.env.BOOKS_PATH,
bookBucketPath: process.env.BOOK_BUCKET_PATH,
appDataPath: process.env.APP_DATA_PATH,
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
process.env.APP_DATA_PATH = fixture.booksPath;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -305,9 +302,6 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
else process.env.BOOKS_PATH = snapshot.booksPath;
if (snapshot.bookBucketPath === undefined) delete process.env.BOOK_BUCKET_PATH;
else process.env.BOOK_BUCKET_PATH = snapshot.bookBucketPath;
if (snapshot.appDataPath === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = snapshot.appDataPath;
}