app.ts
Rama main · réplica de lectura del repositorio original.
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/<id>/ 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);
// });