diff --git a/Dockerfile b/Dockerfile index f2c1206..141188d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,11 +67,16 @@ COPY --from=builder /app/next-env.d.ts ./ COPY --from=builder /app/tailwind.config.ts ./ COPY --from=builder /app/scripts ./scripts +# Copy entrypoint script +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + # Add non-root user for security RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs && \ mkdir -p /app/.cache && \ - chown -R nextjs:nodejs /app /app/.cache + chown -R nextjs:nodejs /app /app/.cache && \ + chown nextjs:nodejs docker-entrypoint.sh USER nextjs @@ -86,5 +91,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 -# Start the application (init DB then start) -CMD ["pnpm", "start:prod"] \ No newline at end of file +# Start the application (push schema, init DB, then start) +CMD ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..51265e0 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +echo "🔄 Pushing Prisma schema to database..." +npx prisma db push --skip-generate --accept-data-loss + +echo "🔧 Initializing database..." +node scripts/init-db.mjs + +echo "🚀 Starting application..." +exec pnpm start + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de68aeb..8145bed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,16 +43,17 @@ model KomgaConfig { } model TTLConfig { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @unique - defaultTTL Int @default(5) - homeTTL Int @default(5) - librariesTTL Int @default(1440) - seriesTTL Int @default(5) - booksTTL Int @default(5) - imagesTTL Int @default(1440) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @unique + defaultTTL Int @default(5) + homeTTL Int @default(5) + librariesTTL Int @default(1440) + seriesTTL Int @default(5) + booksTTL Int @default(5) + imagesTTL Int @default(1440) + imageCacheMaxAge Int @default(2592000) // 30 jours en secondes + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/komga/ttl-config/route.ts b/src/app/api/komga/ttl-config/route.ts index 9258560..f0e01da 100644 --- a/src/app/api/komga/ttl-config/route.ts +++ b/src/app/api/komga/ttl-config/route.ts @@ -52,6 +52,7 @@ export async function POST(request: NextRequest) { seriesTTL: config.seriesTTL, booksTTL: config.booksTTL, imagesTTL: config.imagesTTL, + imageCacheMaxAge: config.imageCacheMaxAge, }, }); } catch (error) { diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index ca9bae1..87e4250 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -10,6 +10,7 @@ import { usePathname } from "next/navigation"; import { registerServiceWorker } from "@/lib/registerSW"; import { NetworkStatus } from "../ui/NetworkStatus"; import { usePreferences } from "@/contexts/PreferencesContext"; +import { ImageCacheProvider } from "@/contexts/ImageCacheContext"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; // Routes qui ne nécessitent pas d'authentification @@ -135,38 +136,40 @@ export default function ClientLayout({ children, initialLibraries = [], initialF return ( - {/* Background fixe pour les images et gradients */} - {hasCustomBackground && ( + + {/* Background fixe pour les images et gradients */} + {hasCustomBackground && ( + + )} - )} - - {!isPublicRoute && ( - - )} - {!isPublicRoute && ( - - )} - {children} - - - - + className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`} + style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined} + > + {!isPublicRoute && ( + + )} + {!isPublicRoute && ( + + )} + {children} + + + + + ); } diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index ee2e205..4b6a7c5 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -3,12 +3,13 @@ import { useState, useEffect } from "react"; import { useTranslate } from "@/hooks/useTranslate"; import { useToast } from "@/components/ui/use-toast"; -import { Trash2, Loader2, HardDrive, List, ChevronDown, ChevronUp } from "lucide-react"; +import { Trash2, Loader2, HardDrive, List, ChevronDown, ChevronUp, ImageOff } from "lucide-react"; import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch"; import { Label } from "@/components/ui/label"; import type { TTLConfigData } from "@/types/komga"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useImageCache } from "@/contexts/ImageCacheContext"; interface CacheSettingsProps { initialTTLConfig: TTLConfigData | null; @@ -35,6 +36,7 @@ interface ServiceWorkerCacheEntry { export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { const { t } = useTranslate(); const { toast } = useToast(); + const { flushImageCache } = useImageCache(); const [isCacheClearing, setIsCacheClearing] = useState(false); const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false); const [serverCacheSize, setServerCacheSize] = useState(null); @@ -56,6 +58,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { seriesTTL: 5, booksTTL: 5, imagesTTL: 1440, + imageCacheMaxAge: 2592000, } ); @@ -389,7 +392,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { } }; - const handleTTLChange = (event: React.ChangeEvent) => { + const handleFlushImageCache = () => { + flushImageCache(); + toast({ + title: t("settings.cache.title"), + description: t("settings.cache.messages.imageCacheFlushed"), + }); + }; + + const handleTTLChange = (event: React.ChangeEvent) => { const { name, value } = event.target; setTTLConfig((prev) => ({ ...prev, @@ -788,6 +799,30 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" /> + + + + {t("settings.cache.ttl.imageCacheMaxAge.label")} + + + {t("settings.cache.ttl.imageCacheMaxAge.description")} + + + + {t("settings.cache.ttl.imageCacheMaxAge.options.noCache")} + {t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")} + {t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")} + {t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")} + {t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")} + {t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")} + + + + + + {t("settings.cache.buttons.flushImageCache")} + + diff --git a/src/components/ui/book-cover.tsx b/src/components/ui/book-cover.tsx index 6f13330..f962bba 100644 --- a/src/components/ui/book-cover.tsx +++ b/src/components/ui/book-cover.tsx @@ -4,6 +4,7 @@ import { CoverClient } from "./cover-client"; import { ProgressBar } from "./progress-bar"; import type { BookCoverProps } from "./cover-utils"; import { getImageUrl } from "./cover-utils"; +import { useImageUrl } from "@/hooks/useImageUrl"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { MarkAsReadButton } from "./mark-as-read-button"; import { MarkAsUnreadButton } from "./mark-as-unread-button"; @@ -59,7 +60,8 @@ export function BookCover({ }: BookCoverProps) { const { t } = useTranslate(); - const imageUrl = getImageUrl("book", book.id); + const baseUrl = getImageUrl("book", book.id); + const imageUrl = useImageUrl(baseUrl); const isCompleted = book.readProgress?.completed || false; const currentPage = ClientOfflineBookService.getCurrentPage(book); diff --git a/src/components/ui/cover-utils.tsx b/src/components/ui/cover-utils.tsx index 74ce274..4873841 100644 --- a/src/components/ui/cover-utils.tsx +++ b/src/components/ui/cover-utils.tsx @@ -20,6 +20,10 @@ export interface SeriesCoverProps extends BaseCoverProps { series: KomgaSeries; } +/** + * Génère l'URL de base pour une image (sans cache version) + * Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting + */ export function getImageUrl(type: "series" | "book", id: string) { if (type === "series") { return `/api/komga/images/series/${id}/thumbnail`; diff --git a/src/components/ui/series-cover.tsx b/src/components/ui/series-cover.tsx index 04720ba..2a6af6f 100644 --- a/src/components/ui/series-cover.tsx +++ b/src/components/ui/series-cover.tsx @@ -4,6 +4,7 @@ import { CoverClient } from "./cover-client"; import { ProgressBar } from "./progress-bar"; import type { SeriesCoverProps } from "./cover-utils"; import { getImageUrl } from "./cover-utils"; +import { useImageUrl } from "@/hooks/useImageUrl"; export function SeriesCover({ series, @@ -11,7 +12,8 @@ export function SeriesCover({ className, showProgressUi = true, }: SeriesCoverProps) { - const imageUrl = getImageUrl("series", series.id); + const baseUrl = getImageUrl("series", series.id); + const imageUrl = useImageUrl(baseUrl); const isCompleted = series.booksCount === series.booksReadCount; const readBooks = series.booksReadCount; diff --git a/src/contexts/ImageCacheContext.tsx b/src/contexts/ImageCacheContext.tsx new file mode 100644 index 0000000..295dd40 --- /dev/null +++ b/src/contexts/ImageCacheContext.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; + +interface ImageCacheContextType { + cacheVersion: string; + flushImageCache: () => void; + getImageUrl: (baseUrl: string) => string; +} + +const ImageCacheContext = createContext(undefined); + +export function ImageCacheProvider({ children }: { children: React.ReactNode }) { + const [cacheVersion, setCacheVersion] = useState(""); + + // Initialiser la version depuis localStorage au montage + useEffect(() => { + const storedVersion = localStorage.getItem("imageCacheVersion"); + if (storedVersion) { + setCacheVersion(storedVersion); + } else { + const newVersion = Date.now().toString(); + setCacheVersion(newVersion); + localStorage.setItem("imageCacheVersion", newVersion); + } + }, []); + + const flushImageCache = useCallback(() => { + const newVersion = Date.now().toString(); + setCacheVersion(newVersion); + localStorage.setItem("imageCacheVersion", newVersion); + // eslint-disable-next-line no-console + console.log("🗑️ Image cache flushed - new version:", newVersion); + }, []); + + const getImageUrl = useCallback( + (baseUrl: string) => { + if (!cacheVersion) return baseUrl; + const separator = baseUrl.includes("?") ? "&" : "?"; + return `${baseUrl}${separator}v=${cacheVersion}`; + }, + [cacheVersion] + ); + + return ( + + {children} + + ); +} + +export function useImageCache() { + const context = useContext(ImageCacheContext); + if (context === undefined) { + throw new Error("useImageCache must be used within an ImageCacheProvider"); + } + return context; +} + diff --git a/src/hooks/useImageUrl.ts b/src/hooks/useImageUrl.ts new file mode 100644 index 0000000..71176f3 --- /dev/null +++ b/src/hooks/useImageUrl.ts @@ -0,0 +1,15 @@ +import { useImageCache } from "@/contexts/ImageCacheContext"; +import { useMemo } from "react"; + +/** + * Hook pour obtenir une URL d'image avec cache busting + * Ajoute automatiquement ?v={cacheVersion} à l'URL + */ +export function useImageUrl(baseUrl: string): string { + const { getImageUrl } = useImageCache(); + + return useMemo(() => { + return getImageUrl(baseUrl); + }, [baseUrl, getImageUrl]); +} + diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 386f970..6204778 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -145,19 +145,33 @@ "libraries": "Libraries TTL", "series": "Series TTL", "books": "Books TTL", - "images": "Images TTL" + "images": "Images TTL", + "imageCacheMaxAge": { + "label": "HTTP Image Cache Duration", + "description": "Duration for images to be cached in the browser", + "options": { + "noCache": "No cache (0s)", + "oneHour": "1 hour (3600s)", + "oneDay": "1 day (86400s)", + "oneWeek": "1 week (604800s)", + "oneMonth": "1 month (2592000s) - Recommended", + "oneYear": "1 year (31536000s)" + } + } }, "buttons": { "saveTTL": "Save TTL", "clear": "Clear cache", "clearing": "Clearing...", "clearServiceWorker": "Clear service worker cache", - "clearingServiceWorker": "Clearing service worker cache..." + "clearingServiceWorker": "Clearing service worker cache...", + "flushImageCache": "Force reload images" }, "messages": { "ttlSaved": "TTL configuration saved successfully", "cleared": "Server cache cleared successfully", - "serviceWorkerCleared": "Service worker cache cleared successfully" + "serviceWorkerCleared": "Service worker cache cleared successfully", + "imageCacheFlushed": "Images will be reloaded - refresh the page" }, "error": { "title": "Error clearing cache", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 54041c9..f735147 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -145,19 +145,33 @@ "libraries": "TTL bibliothèques", "series": "TTL séries", "books": "TTL tomes", - "images": "TTL images" + "images": "TTL images", + "imageCacheMaxAge": { + "label": "Durée du cache HTTP des images", + "description": "Durée de conservation des images dans le cache du navigateur", + "options": { + "noCache": "Aucun cache (0s)", + "oneHour": "1 heure (3600s)", + "oneDay": "1 jour (86400s)", + "oneWeek": "1 semaine (604800s)", + "oneMonth": "1 mois (2592000s) - Recommandé", + "oneYear": "1 an (31536000s)" + } + } }, "buttons": { "saveTTL": "Sauvegarder les TTL", "clear": "Vider le cache", "clearing": "Suppression...", "clearServiceWorker": "Vider le cache du service worker", - "clearingServiceWorker": "Suppression du cache service worker..." + "clearingServiceWorker": "Suppression du cache service worker...", + "flushImageCache": "Forcer le rechargement des images" }, "messages": { "ttlSaved": "La configuration des TTL a été sauvegardée avec succès", "cleared": "Cache serveur supprimé avec succès", - "serviceWorkerCleared": "Cache du service worker supprimé avec succès" + "serviceWorkerCleared": "Cache du service worker supprimé avec succès", + "imageCacheFlushed": "Les images seront rechargées - rafraîchissez la page" }, "error": { "title": "Erreur lors de la suppression du cache", diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index b95c9ce..5113cd0 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -1,14 +1,25 @@ import { BaseApiService } from "./base-api.service"; -import type { KomgaBook, KomgaBookWithPages } from "@/types/komga"; +import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga"; import type { ImageResponse } from "./image.service"; import { ImageService } from "./image.service"; import { PreferencesService } from "./preferences.service"; +import { ConfigDBService } from "./config-db.service"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; import { SeriesService } from "./series.service"; import type { Series } from "@/types/series"; export class BookService extends BaseApiService { + private static async getImageCacheMaxAge(): Promise { + try { + const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig(); + const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000; + return maxAge; + } catch (error) { + console.error('[ImageCache] Error fetching TTL config:', error); + return 2592000; // 30 jours par défaut en cas d'erreur + } + } static async getBook(bookId: string): Promise { try { return this.fetchWithCache( @@ -102,10 +113,12 @@ export class BookService extends BaseApiService { response.buffer.byteOffset + response.buffer.byteLength ) as ArrayBuffer; + const maxAge = await this.getImageCacheMaxAge(); + return new Response(arrayBuffer, { headers: { "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": "public, max-age=31536000, immutable", + "Cache-Control": `public, max-age=${maxAge}, immutable`, }, }); } catch (error) { @@ -117,6 +130,7 @@ export class BookService extends BaseApiService { try { // Récupérer les préférences de l'utilisateur const preferences = await PreferencesService.getPreferences(); + const maxAge = await this.getImageCacheMaxAge(); // Si l'utilisateur préfère les vignettes, utiliser la miniature if (preferences.showThumbnails) { @@ -124,7 +138,7 @@ export class BookService extends BaseApiService { return new Response(response.buffer.buffer as ArrayBuffer, { headers: { "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": "public, max-age=31536000, immutable", + "Cache-Control": `public, max-age=${maxAge}, immutable`, }, }); } @@ -149,10 +163,12 @@ export class BookService extends BaseApiService { const response: ImageResponse = await ImageService.getImage( `books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true` ); + const maxAge = await this.getImageCacheMaxAge(); + return new Response(response.buffer.buffer as ArrayBuffer, { headers: { "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": "public, max-age=31536000, immutable", + "Cache-Control": `public, max-age=${maxAge}, immutable`, }, }); } catch (error) { diff --git a/src/lib/services/config-db.service.ts b/src/lib/services/config-db.service.ts index e8715c2..40b5603 100644 --- a/src/lib/services/config-db.service.ts +++ b/src/lib/services/config-db.service.ts @@ -90,6 +90,7 @@ export class ConfigDBService { seriesTTL: data.seriesTTL, booksTTL: data.booksTTL, imagesTTL: data.imagesTTL, + imageCacheMaxAge: data.imageCacheMaxAge, }, create: { userId: user.id, @@ -99,6 +100,7 @@ export class ConfigDBService { seriesTTL: data.seriesTTL, booksTTL: data.booksTTL, imagesTTL: data.imagesTTL, + imageCacheMaxAge: data.imageCacheMaxAge, }, }); diff --git a/src/lib/services/series.service.ts b/src/lib/services/series.service.ts index c9a1c7c..8414105 100644 --- a/src/lib/services/series.service.ts +++ b/src/lib/services/series.service.ts @@ -1,10 +1,11 @@ import { BaseApiService } from "./base-api.service"; import type { LibraryResponse } from "@/types/library"; -import type { KomgaBook, KomgaSeries } from "@/types/komga"; +import type { KomgaBook, KomgaSeries, TTLConfig } from "@/types/komga"; import { BookService } from "./book.service"; import type { ImageResponse } from "./image.service"; import { ImageService } from "./image.service"; import { PreferencesService } from "./preferences.service"; +import { ConfigDBService } from "./config-db.service"; import { getServerCacheService } from "./server-cache.service"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; @@ -12,6 +13,16 @@ import type { UserPreferences } from "@/types/preferences"; import type { ServerCacheService } from "./server-cache.service"; export class SeriesService extends BaseApiService { + private static async getImageCacheMaxAge(): Promise { + try { + const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig(); + const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000; + return maxAge; + } catch (error) { + console.error('[ImageCache] Error fetching TTL config:', error); + return 2592000; // 30 jours par défaut en cas d'erreur + } + } static async getSeries(seriesId: string): Promise { try { return this.fetchWithCache( @@ -179,6 +190,7 @@ export class SeriesService extends BaseApiService { try { // Récupérer les préférences de l'utilisateur const preferences: UserPreferences = await PreferencesService.getPreferences(); + const maxAge = await this.getImageCacheMaxAge(); // Si l'utilisateur préfère les vignettes, utiliser la miniature if (preferences.showThumbnails) { @@ -186,7 +198,7 @@ export class SeriesService extends BaseApiService { return new Response(response.buffer.buffer as ArrayBuffer, { headers: { "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": "public, max-age=31536000, immutable", + "Cache-Control": `public, max-age=${maxAge}, immutable`, }, }); } diff --git a/src/types/komga.ts b/src/types/komga.ts index 98e05c3..bfc341a 100644 --- a/src/types/komga.ts +++ b/src/types/komga.ts @@ -22,6 +22,7 @@ export interface TTLConfigData { seriesTTL: number; booksTTL: number; imagesTTL: number; + imageCacheMaxAge: number; // en secondes } export interface TTLConfig extends TTLConfigData {
+ {t("settings.cache.ttl.imageCacheMaxAge.description")} +