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}`); }); })();