Ctrl K
ComunidadGuías…

app.js

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

Archivo original ↗Preguntar a KiBot
require('dotenv').config({ quiet: true });
const http = require('http');
const express = require('express');
const session = require('express-session');
const { MongoStore } = require('connect-mongo');
const path = require('path');
const fs = require('fs');
const net = require('net');
const { rateLimit } = require('express-rate-limit');
const morgan = require('morgan');
const mongoose = require('mongoose');
const connectDB = require('./src/config/db');
const SiteSettings = require('./src/models/SiteSettings');
const uploadMiddleware = require('./src/middleware/upload');
const { requireApiKey } = require('./src/middleware/auth');
const File = require('./src/models/File');
const User = require('./src/models/User');
const { initWS, broadcast } = require('./src/ws');

const migrateUserFolders = require('./src/migrations/migrateUserFolders');
const migrateApiKeyHashes = require('./src/migrations/migrateApiKeyHashes');
const sanitizeFilename = require('./src/utils/sanitizeFilename');
const { normalizeMime } = require('./src/utils/mime');
const { sealUpload, startThumbnail, discardUncommittedUploads } = require('./src/utils/storeUpload');
const { logAudit } = require('./src/utils/audit');
const { runRetentionCleanup } = require('./src/jobs/retentionCleanup');
const { wrapAsync } = require('./src/utils/asyncRouter');
const { lookupLimiter } = require('./src/middleware/lookupLimiter');
const { announceSetupToken } = require('./src/utils/setupToken');
const fileCrypto = require('./src/utils/fileCrypto');

// Survive transient failures (mostly MongoDB network blips) instead of letting
// the container restart policy turn them into a crash-loop. A rejected DB
// operation does not corrupt process state, so we log and keep serving; the
// mongoose driver reconnects on its own once the database is reachable again.
process.on('unhandledRejection', (reason) => {
  console.error('Unhandled promise rejection:', reason instanceof Error ? reason.stack : reason);
});
// A synchronous throw that reached the top leaves state undefined (half-written
// files, open handles); exit and let the container restart policy bring us back.
process.on('uncaughtException', (err) => {
  console.error('Uncaught exception, exiting:', err.stack || err);
  process.exit(1);
});

if (!process.env.SESSION_SECRET) {
  console.error('FATAL: SESSION_SECRET environment variable is not set.');
  process.exit(1);
}
if (fileCrypto.getKeyError()) {
  console.error(`FATAL: ${fileCrypto.getKeyError()}`);
  process.exit(1);
}

const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads');

const app = express();

// Trust the first proxy (nginx / Cloudflare) so that express-rate-limit can
// read the real client IP from the X-Forwarded-For header without throwing
// ERR_ERL_UNEXPECTED_X_FORWARDED_FOR. TRUST_PROXY must match the actual proxy
// chain (e.g. 2 for Cloudflare → nginx), otherwise client IPs are wrong or spoofable.
function parseTrustProxy(value) {
  if (value === undefined || value === '') return 1;
  if (/^\d+$/.test(value)) return Number(value);
  if (value === 'true' || value === 'false') return value === 'true';
  return value;
}
app.set('trust proxy', parseTrustProxy(process.env.TRUST_PROXY));

// Concise request logging to stdout (picked up by the Docker json-file driver).
// Skips the container healthcheck (wget) and static bundle assets so the log
// stays readable and its capped history keeps actual activity instead of noise.
const SKIP_LOG = /\.(?:js|css|map|png|jpe?g|gif|svg|ico|webp|woff2?|ttf)$/i;
// Credentials travel in some URLs (API key query, delete/share/email tokens);
// keep them out of the logs.
morgan.token('safe-url', (req) => (req.originalUrl || req.url)
  .replace(/([?&](?:api_key|token)=)[^&#]*/gi, '$1[redacted]')
  .replace(/^(\/f\/[^/?#]+\/delete\/)[^/?#]+/, '$1[redacted]')
  .replace(/^(\/s\/|\/api\/share-links\/)[^/?#]+/, '$1[redacted]'));
// Docker logs are only rotated by size, so they can outlive any stated retention
// period; keep them free of full client IPs (IPv4 /24, IPv6 /48). The audit log
// keeps full IPs for its 90-day TTL.
function anonymizeIp(ip) {
  if (!ip) return '-';
  const v4 = ip.replace(/^::ffff:/i, '');
  if (net.isIPv4(v4)) return v4.replace(/\.\d+$/, '.0');
  if (!net.isIPv6(ip)) return '-';
  const [head, tail] = ip.split('::');
  const h = head ? head.split(':') : [];
  const t = tail ? tail.split(':') : [];
  const groups = tail === undefined ? h : [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill('0'), ...t];
  return `${groups.slice(0, 3).join(':')}::`;
}
morgan.token('anon-addr', (req) => anonymizeIp(req.ip || req.socket?.remoteAddress));
app.use(morgan(':date[iso] :anon-addr :method :safe-url :status :res[content-length]B :response-time ms', {
  skip: (req) => req.headers['user-agent']?.startsWith('Wget') || SKIP_LOG.test(req.path),
}));

app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Express 5 leaves req.body undefined when no parser ran (e.g. bodyless POSTs);
// the routes destructure it and rely on the Express 4 default.
app.use((req, _res, next) => { if (req.body === undefined) req.body = {}; next(); });

app.use((req, res, next) => {
  // Only meaningful over HTTPS (req.secure honours X-Forwarded-Proto via trust proxy).
  if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  // Share, paste and file URLs are secrets; keep them out of Referer headers sent
  // to external links. Not no-referrer: that can turn Origin into "null" on
  // same-origin form posts, which requireSameOrigin would reject.
  res.setHeader('Referrer-Policy', 'same-origin');
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
  res.setHeader(
    'Content-Security-Policy',
    // static.cloudflareinsights.com is allowed in script-src so the Cloudflare
    // Web Analytics beacon (injected by the Cloudflare proxy) can load.
    // cloudflareinsights.com is allowed in connect-src so the beacon can report
    // back to Cloudflare's collection endpoint.
    // frame-ancestors 'self' allows the PDF viewer iframe (same-origin) to work.
    "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; base-uri 'none'; form-action 'self'; object-src 'none';",
  );
  next();
});

// Establish the single shared MongoDB connection up front. The session store
// reuses this connection instead of opening its own client: a second, unmanaged
// client connects at import time — before connectDB runs and before container
// DNS is reliably ready — and its failure crashes the process with an unhandled
// MongoServerSelectionError that db.js's try/catch never sees.
const dbReady = connectDB();

const sessionMiddleware = session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  // touchAfter makes session touches lazy: the maxAge middleware below resets
  // cookie.maxAge on every request, which otherwise writes the session to Mongo
  // on every authenticated request. With 7-day sessions a daily touch is enough.
  store: MongoStore.create({
    clientPromise: dbReady.then(() => mongoose.connection.getClient()),
    touchAfter: 24 * 3600,
  }),
  cookie: {
    maxAge: 1000 * 60 * 60 * 24 * 7,
    httpOnly: true,
    // 'auto': Secure whenever the request arrived via HTTPS, independent of NODE_ENV
    // (which the Docker setup does not set); plain-HTTP local setups keep working.
    secure: 'auto',
    sameSite: 'strict',
  },
});
app.use(sessionMiddleware);

// Dynamically apply session duration from SiteSettings (cached for 60 s).
let _cachedSessionMs = null;
let _sessionCacheTs = 0;
async function getSessionMaxAge() {
  const now = Date.now();
  if (_cachedSessionMs !== null && now - _sessionCacheTs < 60_000) return _cachedSessionMs;
  try {
    const s = await SiteSettings.get();
    _cachedSessionMs = (s.sessionDurationDays || 7) * 24 * 60 * 60 * 1000;
  } catch {
    _cachedSessionMs = 7 * 24 * 60 * 60 * 1000;
  }
  _sessionCacheTs = Date.now();
  return _cachedSessionMs;
}
app.use(async (req, _res, next) => {
  if (req.session) req.session.cookie.maxAge = await getSessionMaxAge();
  next();
});

// The session only holds a login-time snapshot of the user; re-read it so
// deletion, deactivation, demotion and password changes take effect at once.
app.use(async (req, _res, next) => {
  const sessUser = req.session?.user;
  if (!sessUser || SKIP_LOG.test(req.path)) return next();
  try {
    const user = await User.findById(sessUser.id).select('username role isActive sessionVersion').lean();
    if (!user || !user.isActive || (user.sessionVersion || 0) !== (sessUser.sv || 0)) {
      // regenerate instead of destroy: handlers still expect req.session to exist.
      return req.session.regenerate((err) => next(err));
    }
    if (sessUser.role !== user.role || sessUser.username !== user.username) {
      req.session.user = { ...sessUser, role: user.role, username: user.username };
    }
    next();
  } catch (err) {
    next(err);
  }
});

const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 60,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many uploads, please try again later.' },
});

// ShareX upload endpoint — multer runs first so req.body.token is available for auth.
// Because auth runs after multer, the file lands in the root uploads dir initially;
// we move it into the user's subfolder once the user identity is known.
app.post('/upload', uploadLimiter, discardUncommittedUploads, uploadMiddleware.single('upload'), wrapAsync(requireApiKey), wrapAsync(async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file provided' });

  const user = req.apiUser;
  const folder = user.folderName || user.username;
  let storedName = req.file.filename;
  let diskPath = req.file.path;

  if (folder) {
    const userDir = path.join(UPLOAD_DIR, folder);
    fs.mkdirSync(userDir, { recursive: true });
    const newPath = path.join(userDir, req.file.filename);
    fs.renameSync(req.file.path, newPath);
    req.file.path = newPath;
    storedName = path.join(folder, req.file.filename);
    diskPath = newPath;
  }

  const mimeType = normalizeMime(req.file.mimetype);
  const sealed = await sealUpload(diskPath);
  req.file.sealed = sealed;
  const file = await File.createUnique({
    originalName: sanitizeFilename(req.file.originalname),
    storedName,
    mimeType,
    encrypted: sealed.encrypted,
    size: req.file.size,
    uploader: user._id,
  });
  req.file.committed = true;
  startThumbnail(sealed, mimeType, file.shortId);
  await logAudit(req, 'upload', { fileName: file.originalName, fileSize: file.size, shortId: file.shortId });
  const uploaderId = String(user._id);
  broadcast('file:uploaded', { shortId: file.shortId, uploaderId }, (c) => c.userId === uploaderId);
  broadcast('stats:invalidate', {}, (c) => c.isAdmin);
  const base = process.env.BASE_URL || 'http://localhost:3000';
  res.json({
    url: `${base}/f/${file.shortId}`,
    delete_url: `${base}/f/${file.shortId}/delete/${file.deleteToken}`,
  });
}));

// CSRF protection: reject cross-origin requests by comparing Origin to Host.
// sameSite:'strict' cookies already block most CSRF; this is an additional layer.
function requireSameOrigin(req, res, next) {
  const origin = req.headers.origin;
  if (!origin) return next(); // no Origin header → same-origin or non-browser request
  try {
    if (new URL(origin).host !== req.headers.host) {
      return res.status(403).json({ error: 'Forbidden' });
    }
  } catch {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
}

// JSON API routes
app.use('/api/install', requireSameOrigin, require('./src/routes/install'));
app.use('/api/auth', requireSameOrigin, require('./src/routes/auth'));
app.use('/api/admin/import', requireSameOrigin, require('./src/routes/import'));
app.use('/api/pastes', requireSameOrigin, require('./src/routes/pastes'));
app.use('/api', requireSameOrigin, require('./src/routes/api'));

// Raw file serving (not JSON)
app.use('/f', lookupLimiter, require('./src/routes/files'));

// Share link file serving (password/expiry/download-limit checked server-side)
app.use('/s', lookupLimiter, require('./src/routes/shares'));

// Documentation page — only served on sharely.christian.pizza
app.use('/docs', require('./src/routes/docs'));

// Serve React SPA in production
const clientDist = path.join(__dirname, 'client/dist');
// index:false so the catch-all serves a revalidated index.html; the content-
// hashed bundles under /assets are immutable and cached for a year, the rest
// (favicon) for an hour.
app.use(express.static(clientDist, {
  index: false,
  setHeaders: (res, filePath) => {
    if (filePath.includes(`${path.sep}assets${path.sep}`)) {
      res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
    } else {
      res.setHeader('Cache-Control', 'public, max-age=3600');
    }
  },
}));
app.get('/{*splat}', (_req, res) => {
  res.sendFile(path.join(clientDist, 'index.html'));
});

app.use((err, _req, res, next) => {
  console.error(err.stack);
  // Mid-stream failure (e.g. file download): let Express abort the connection.
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'Internal server error' });
});

const PORT = process.env.PORT || 3000;

(async () => {
  await dbReady;
  await migrateUserFolders();
  await migrateApiKeyHashes();
  await runRetentionCleanup();
  setInterval(runRetentionCleanup, 24 * 60 * 60 * 1000);
  if (await User.countDocuments() === 0) announceSetupToken();
  if (!fileCrypto.isEnabled() && await File.exists({ encrypted: true })) {
    console.error('[crypto] Encrypted files exist but ENCRYPTION_KEY is not set; they cannot be served.');
  }
  const server = http.createServer(app);
  initWS(server, sessionMiddleware, app.get('trust proxy fn'));
  server.listen(PORT, () => {
    console.log(`sharely running on http://localhost:${PORT}`);
  });
})();