mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: improve header UI and optimize docker storage configuration
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,15 +211,16 @@ 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_URL` | `http://localhost:5173` | Client URL (used in emails) |
|
||||
| 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` |
|
||||
| `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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}'),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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));
|
||||
await this.startWatcher(
|
||||
lib.id,
|
||||
folders.map((f) => f.path),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user