Ctrl K
ComunidadGuías…

app.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
// });