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