import express from 'express'; import session from 'express-session'; import path from 'path'; import { checkUser } from './middleware/authMiddleware.js'; import cookieParser from 'cookie-parser'; import mongoose from 'mongoose'; import http from 'http'; import { Server } from "socket.io"; import i18next from 'i18next'; import i18nMiddleware from 'i18next-http-middleware'; import settingsMiddleware from './middleware/settingsMiddleware.js'; import collectionMiddleware from './middleware/collectionMiddleware.js'; import themesConfig from './config/themes.js'; import { BASE_URL, SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE, normalizeLanguage, dateLocaleFor } from './config/constants.js'; import { isOidcEnabled, getOidcButtonLabel, isLocalLoginDisabled } from './config/oidc.js'; import { connectDB } from './config/db.js'; import { migrateDatabase } from './utils/migrate.js'; import { migrateExtraFieldIdentities } from './utils/migrateExtraFieldIdentities.js'; // Models import User from './models/User.js'; import BlockedIP from './models/blockedIP.js'; // Core & Registry import { registry } from './core/registry.js'; import { loadPlugins } from './core/loadPlugins.js'; import { syncCustomPluginsOnBoot } from './core/customPluginSync.js'; import { mountPluginRoutes, pluginDispatcher } from './core/pluginRuntime.js'; import { applyPluginCustomization } from './core/pluginCustomization.js'; import { getCardLines, getCornerBadge, isTranslationKey, CORNER_POSITIONS, DEFAULT_CORNER_POSITION, SHARE_HIDDEN_FIELDS } from './core/cardFields.js'; import { highlightMatches } from './core/searchHighlight.js'; import { importableFields } from './core/csvMapping.js'; import { MAX_ITEM_IMAGES, MAX_ITEM_IMAGE_BYTES } from './core/itemImages.js'; import { cleanupStaleItemImageUploads, ITEM_IMAGE_SWEEP_INTERVAL_MS, itemImageUrl } from './core/itemImageStorage.js'; import { isCollectionInfoVisible } from './core/collectionInfo.js'; import { externalLinkFor, hasSearch, hasBarcodeScan, requiredEnvKeysFor, canRefresh, sourceStatusFor, searchableSources } from './core/sources'; // Routes imports import setupRoutes from './routes/setupRoutes.js'; import pluginBuilderRoutes from './routes/pluginBuilderRoutes.js'; import pluginAssetRoutes from './routes/pluginAssetRoutes.js'; import authRoutes from './routes/authRoutes.js'; import shareRoutes from './routes/shareRoutes.js'; import adminRoutes from './routes/adminRoutes.js'; import settingsRoutes from './routes/settingsRoutes.js'; import backupRoutes from './routes/backupRoutes.js'; import itemImageRoutes from './routes/itemImageRoutes.js'; import oidcRoutes from './routes/oidcRoutes.js'; import dashboardRoute from './core/routes/dashboardRoute.js'; import collectionRoute from './core/routes/collectionRoute.js'; import collectionInfoRoute from './core/routes/collectionInfoRoute.js'; import shelfRoute from './core/routes/shelfRoute.js'; import listRoute from './core/routes/listRoute.js'; import searchRoute from './core/routes/searchRoute.js'; import accountRoute from './core/routes/accountRoute.js'; import manualAddRoute from './core/routes/manualAddRoute.js'; import csvImportRoute from './core/routes/csvImportRoute.js'; import aiImportRoute from './core/routes/aiImportRoute.js'; const app = express(); const server = http.createServer(app); const io = new Server(server, { path: BASE_URL + '/socket.io', }); i18next .use(i18nMiddleware.LanguageDetector) // Detect language via query/cookie/header .init({ fallbackLng: DEFAULT_LANGUAGE, // Without these two, req.language keeps the regional tag the browser sends // ('fr-FR'). It is stored on the user and used as a lookup key (TMDB), both of // which only know the short codes, so the language is resolved to one of them here. supportedLngs: [...SUPPORTED_LANGUAGES], nonExplicitSupportedLngs: true, preload: [...SUPPORTED_LANGUAGES], resources: { en: { translation: require('./locales/en.json') }, fr: { translation: require('./locales/fr.json') }, es: { translation: require('./locales/es.json') }, it: { translation: require('./locales/it.json') }, de: { translation: require('./locales/de.json') } }, detection: { order: ['querystring', 'cookie', 'header'], // detection order caches: ['cookie'] } }); // Basic configuration app.set('view engine', 'ejs'); app.set('views', [path.join(__dirname, 'views'), path.join(__dirname, 'core/views')]); // Card bodies are resolved from the plugin declarations, not inlined per grid app.locals.getCardLines = getCardLines; app.locals.getCornerBadge = getCornerBadge; app.locals.isTranslationKey = isTranslationKey; // Lights up the searched text on the cards and table rows it brought back app.locals.highlightMatches = highlightMatches; app.locals.CORNER_POSITIONS = CORNER_POSITIONS; app.locals.DEFAULT_CORNER_POSITION = DEFAULT_CORNER_POSITION; // A share visitor is shown the collection, not the home around it: the item page reads // the same list the cards do, so a field kept from one is kept from the other app.locals.SHARE_HIDDEN_FIELDS = SHARE_HIDDEN_FIELDS; // The CSV mapping screen lists the destinations of every enabled module app.locals.importableFields = importableFields; // Shared by the image-manager partial so its client-side guard matches the save route. app.locals.MAX_ITEM_IMAGES = MAX_ITEM_IMAGES; app.locals.MAX_ITEM_IMAGE_BYTES = MAX_ITEM_IMAGE_BYTES; // Stored item-upload paths stay deployment-independent; views resolve BASE_URL only // when rendering them so edits and exports keep the portable value. app.locals.itemImageUrl = itemImageUrl; // Dates read the same way wherever a view prints one app.locals.dateLocaleFor = dateLocaleFor; // Every entry point to the collection info page asks the same question before it links // to it, share visitors included (core/collectionInfo.ts). app.locals.isCollectionInfoVisible = isCollectionInfoVisible; // Where an item came from, and whether a plugin can be searched at all: both are now // answers about its sources rather than about a single provider hanging off it. app.locals.externalLinkFor = externalLinkFor; app.locals.hasSearch = hasSearch; app.locals.hasBarcodeScan = hasBarcodeScan; app.locals.requiredEnvKeysFor = requiredEnvKeysFor; app.locals.canRefresh = canRefresh; app.locals.sourceStatusFor = sourceStatusFor; app.locals.searchableSources = searchableSources; app.set('io', io); // Expose io to routes // Global middlewares // nosniff on the whole static tree, because part of it is now supplied by users: an // uploaded item image is only ever served as the image/jpeg its extension declares, // never as whatever a browser might decide the bytes look like. app.use(BASE_URL, express.static(path.join(__dirname, 'public'), { setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff') })); // Mounted with the static assets: no session, no settings, no collection lookup needed app.use(BASE_URL + '/plugin-assets', pluginAssetRoutes); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); app.use(cookieParser()); app.use(i18nMiddleware.handle(i18next)); // Every value DVinyl has ever shipped as a placeholder, from .env.example and from the // Unraid template. They are public, so an instance still running one signs its tokens with // a secret anybody can read. Keep old entries here even after a template stops offering // them: the installs that took them are exactly the ones that never changed them. const INSECURE_DEFAULTS = new Set([ 'SomeComplexPassword', 'AnotherComplexSecret', 'ChangeThisToAComplexPassword', 'ChangeThisToAComplexSecret' ]); for (const name of ['PASSJWT', 'SESSION_SECRET'] as const) { const v = process.env[name]; if (!v) { throw new Error(`[SECURITY] ${name} is not defined. Please set it in your environment.`); } if (INSECURE_DEFAULTS.has(v)) { throw new Error( `[SECURITY] ${name} is still set to the placeholder value shipped with DVinyl ("${v}"), ` + `which is public. Replace it with a unique secret in your .env file or container ` + `settings, then restart (generate one with: openssl rand -hex 32).` ); } if (v.length < 32) { console.warn(`[SECURITY WARNING] ${name} is shorter than 32 characters. Consider generating a longer random secret.`); } } const session_secret = process.env.SESSION_SECRET!; // Sessions live in memory and their cookie has no maxAge, so an entry is only dropped when // the process restarts. Stored only once something is written to them (the collection a // signed-in browser is on, an OIDC round trip): a share visitor, a crawler or a health // check would otherwise leave one behind per request made without a cookie. app.use(session({ secret: session_secret, resave: false, saveUninitialized: false, cookie: { secure: process.env.PROD === 'true', httpOnly: true }, })); if (process.env.PROD === 'true') { app.set('trust proxy', 1); // Trust first proxy } const pkg = require('./package.json'); // Incext BASE_URL in each res.redirect call app.use((req, res, next) => { const redirect = res.redirect as any; res.redirect = function (url: any) { if (url.startsWith('/') && !url.startsWith(BASE_URL)) { return redirect.call(res, `${BASE_URL}${url}`); } else { return redirect.call(res, url); } } as any; next(); }); app.use(checkUser); app.use(async (req: any, res, next) => { // The preference of an authenticated user wins, otherwise the detected language. // Detection hands back the tag the browser sent, region included ('fr-FR'), and // changeLanguage is what rewrites req.language, so it runs on every request: the // value travels into the User schema (enum) and into provider lookups keyed by the // short code, neither of which knows a regional tag. const language = normalizeLanguage(req.user?.language || req.language); if (language !== req.language) { await req.i18n.changeLanguage(language); } // Make translation helper and current language available to all EJS views res.locals.t = req.t; res.locals.currentLng = req.language; res.locals.appVersion = pkg.version; res.locals.baseUrl = BASE_URL; // Where the request lands inside the app, BASE_URL stripped, so the shell can tell // which tab and which navbar shortcut is the current page. res.locals.currentPath = BASE_URL && req.path.startsWith(BASE_URL) ? (req.path.slice(BASE_URL.length) || '/') : req.path; const queryStart = req.originalUrl.indexOf('?'); res.locals.currentSearch = queryStart >= 0 ? req.originalUrl.slice(queryStart + 1) : ''; res.locals.oidcEnabled = isOidcEnabled(); res.locals.oidcButtonLabel = getOidcButtonLabel(); res.locals.localLoginDisabled = isLocalLoginDisabled(); req.io = io; next(); }); // Inject IO object into requests app.use((req: any, res, next) => { req.io = io; next(); }); // Security: IP blocking middleware app.use(async (req: any, res, next) => { const clientIP = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress; try { const blocked = await BlockedIP.findOne({ ip: clientIP }); if (blocked) return res.status(403).send(req.t('common.forbidden')); next(); } catch (err) { console.error('IP error:', err); next(); } }); // Resolve the active collection for the logged-in user. Must run BEFORE // settingsMiddleware: settings are per-collection and need activeCollectionId. app.use(collectionMiddleware); // Load the active collection's settings (per-collection container) app.use(settingsMiddleware); // Installation gatekeeper middleware app.use(async (req, res, next) => { // Ignore paths that should not be redirected during setup if (req.path.startsWith(BASE_URL + '/setup') || req.path.startsWith(BASE_URL + '/ressources') || req.path.startsWith(BASE_URL + '/styles') || req.path.startsWith(BASE_URL + '/login') || req.path.startsWith(BASE_URL + '/backup')) { // allow login and backup import while setting up return next(); } try { const count = await User.countDocuments(); if (count === 0) { return res.redirect(BASE_URL + '/setup'); } } catch (e) { console.error("Check setup error:", e); } next(); }); app.use((req, res, next) => { res.locals.allThemes = themesConfig; // Views read the registry through a per-request facade so the active // collection's cosmetic overrides (settings.pluginCustomization) apply applyPluginCustomization(res); next(); }); // Dynamic manifest.json endpoint - injects BASE_URL app.get(BASE_URL + '/manifest.json', (req, res) => { res.set('Content-Type', 'application/json'); res.render(path.join(__dirname, 'public-tpl', 'manifest.json.ejs')); }); // Dynamic service worker endpoint - injects BASE_URL app.get(BASE_URL + '/sw.js', (req, res) => { res.set('Content-Type', 'application/javascript'); res.set('Service-Worker-Allowed', BASE_URL || '/'); res.render(path.join(__dirname, 'public-tpl', 'sw.js.ejs')); }); // Auto-discover and register every plugin under plugins/ loadPlugins(); // Route mounting app.use(BASE_URL + '/setup', setupRoutes); app.use(BASE_URL, authRoutes); app.use(BASE_URL, shareRoutes); app.use(BASE_URL + '/admin', adminRoutes); app.use(BASE_URL + '/settings', settingsRoutes); app.use(BASE_URL + '/create-plugin', pluginBuilderRoutes); app.use(BASE_URL + '/backup', backupRoutes); app.use(BASE_URL, itemImageRoutes); if (isOidcEnabled()) { app.use(BASE_URL, oidcRoutes); } app.use(BASE_URL, dashboardRoute); app.use(BASE_URL, collectionRoute); app.use(BASE_URL, collectionInfoRoute); app.use(BASE_URL, shelfRoute); app.use(BASE_URL, listRoute); app.use(BASE_URL, searchRoute); app.use(BASE_URL, accountRoute); app.use(BASE_URL, manualAddRoute); // Before the plugin dispatcher, which also serves /import/:id routes app.use(BASE_URL, csvImportRoute); app.use(BASE_URL, aiImportRoute); // Plugin routers live behind a runtime dispatcher (not mounted directly on `app`) // so custom plugins created via /create-plugin are reachable without a restart. for (const plugin of registry.getAll()) { mountPluginRoutes(plugin); } app.use(BASE_URL, pluginDispatcher); app.use((req, res) => { res.status(404).render('404'); }); // Database connection and server start connectDB() .then(async () => { console.log('[BOOT] Running database migrations...'); await migrateDatabase(); // Re-materialize no-code plugins from the DB: re-grows plugins// folders on // a fresh/rebuilt container and backfills the DB from any pre-existing folders. console.log('[BOOT] Syncing custom plugins...'); await syncCustomPluginsOnBoot(); console.log('[BOOT] Migrating custom field identities...'); // Never fatal, like migrateDatabase(): a failure here would otherwise stop the server // from ever listening. It is idempotent, so the next boot picks up where it stopped. try { await migrateExtraFieldIdentities(); } catch (err) { console.error('[BOOT] Custom field identity migration failed:', err); } const sweepAbandonedItemImages = async (label: string) => { try { const removed = await cleanupStaleItemImageUploads(); if (removed > 0) console.log(`[${label}] Removed ${removed} abandoned item image(s)`); } catch (err) { console.warn(`[${label}] Item image cleanup failed:`, err); } }; await sweepAbandonedItemImages('BOOT'); // An upload that never made it onto an item is only swept once it is a day old, so a // boot-only pass leaves an instance that stays up for months collecting them. Repeat // it on a timer, unref'd so it never holds the process open on its own. setInterval(() => { void sweepAbandonedItemImages('CLEANUP'); }, ITEM_IMAGE_SWEEP_INTERVAL_MS).unref(); const port = process.env.VINYL_PORT || 3099; server.listen(port, () => { console.log(`[BOOT] Server started on port ${port} (BASE_URL="${BASE_URL || '/'}", env=${process.env.PROD === 'true' ? 'production' : 'development'})`); }); }) .catch((err: any) => console.error('[BOOT] DB Error:', err)); // Socket event // io.on('connection', (socket) => { // console.log('Connected socket :', socket.id); // });