/** * Monitor-Check – Upload- und Hosting-Server * ------------------------------------------ * - liefert die Weboberflaeche (web/index.html) aus * - nimmt Fotos entgegen, verkleinert sie und legt sie unter /uploads ab * - gibt die oeffentliche URL des Bildes zurueck * * Start: node server.js * Konfig: .env (siehe .env.example) */ const express = require('express'); const multer = require('multer'); const sharp = require('sharp'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); require('dotenv').config(); const PORT = parseInt(process.env.PORT || '8080', 10); const HOST = process.env.HOST || '0.0.0.0'; const TOKEN = process.env.API_TOKEN || ''; const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/+$/, ''); const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')); const WEB_DIR = path.resolve(process.env.WEB_DIR || path.join(__dirname, '..', 'web')); const MAX_MB = parseInt(process.env.MAX_UPLOAD_MB || '15', 10); const MAX_PX = parseInt(process.env.MAX_IMAGE_PX || '1600', 10); const JPEG_Q = parseInt(process.env.JPEG_QUALITY || '78', 10); const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*') .split(',').map(s => s.trim()).filter(Boolean); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const app = express(); app.disable('x-powered-by'); // Anzahl der vorgeschalteten Proxys. Hinter einem zusaetzlichen externen // Reverse-Proxy auf 2 setzen, sonst landen alle Clients in einem Rate-Limit-Topf. app.set('trust proxy', parseInt(process.env.TRUST_PROXY || '1', 10)); /* ---------- CORS ---------- */ app.use((req, res, next) => { const origin = req.headers.origin; if (ALLOWED_ORIGINS.includes('*')) { res.setHeader('Access-Control-Allow-Origin', '*'); } else if (origin && ALLOWED_ORIGINS.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-Token'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); /* ---------- Einfache Rate-Begrenzung ---------- */ const treffer = new Map(); function rateLimit(max, fensterMs) { return (req, res, next) => { const key = req.ip; const jetzt = Date.now(); const e = treffer.get(key) || { n: 0, bis: jetzt + fensterMs }; if (jetzt > e.bis) { e.n = 0; e.bis = jetzt + fensterMs; } e.n++; treffer.set(key, e); if (e.n > max) return res.status(429).json({ ok: false, error: 'Zu viele Anfragen' }); next(); }; } setInterval(() => { const jetzt = Date.now(); for (const [k, v] of treffer) if (jetzt > v.bis) treffer.delete(k); }, 60000).unref(); /* ---------- Token-Pruefung ---------- */ function pruefeToken(req, res, next) { if (!TOKEN) return next(); // ohne Token konfiguriert = offen const mit = req.get('X-Token') || req.query.token || ''; const a = Buffer.from(String(mit)); const b = Buffer.from(TOKEN); const ok = a.length === b.length && crypto.timingSafeEqual(a, b); if (!ok) return res.status(401).json({ ok: false, error: 'Nicht autorisiert' }); next(); } /* ---------- Upload ---------- */ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_MB * 1024 * 1024, files: 1 }, fileFilter: (req, file, cb) => { if (!/^image\//.test(file.mimetype)) return cb(new Error('Nur Bilddateien erlaubt')); cb(null, true); } }); app.post('/api/upload', rateLimit(parseInt(process.env.RATE_LIMIT || '300', 10), 60000), pruefeToken, (req, res) => { upload.single('datei')(req, res, async (err) => { if (err) { const code = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; return res.status(code).json({ ok: false, error: err.code === 'LIMIT_FILE_SIZE' ? `Datei groesser als ${MAX_MB} MB` : err.message }); } if (!req.file) return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' }); try { const name = `${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString('hex')}.jpg`; const ziel = path.join(UPLOAD_DIR, name); await sharp(req.file.buffer, { failOn: 'none' }) .rotate() // EXIF-Ausrichtung anwenden .resize({ width: MAX_PX, height: MAX_PX, fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: JPEG_Q, mozjpeg: true }) .toFile(ziel); const basis = PUBLIC_URL || `${req.protocol}://${req.get('host')}`; const url = `${basis}/uploads/${name}`; const { size } = fs.statSync(ziel); console.log(`[upload] ${name} ${(size / 1024).toFixed(0)} kB von ${req.ip}`); res.json({ ok: true, url, datei: name, bytes: size }); } catch (e) { console.error('[upload] Fehler:', e.message); res.status(500).json({ ok: false, error: 'Bild konnte nicht verarbeitet werden' }); } }); }); /* ---------- Statisches Ausliefern ---------- */ app.use('/uploads', express.static(UPLOAD_DIR, { maxAge: '30d', immutable: true, index: false, setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff') })); app.use('/', express.static(WEB_DIR, { extensions: ['html'], maxAge: '5m' })); app.get('/gesundheit', (req, res) => { res.json({ ok: true, zeit: new Date().toISOString(), uploads: fs.readdirSync(UPLOAD_DIR).length }); }); app.use((req, res) => res.status(404).json({ ok: false, error: 'Nicht gefunden' })); app.listen(PORT, HOST, () => { console.log(`Monitor-Check laeuft auf http://${HOST}:${PORT}`); console.log(` Weboberflaeche: ${WEB_DIR}`); console.log(` Uploads: ${UPLOAD_DIR}`); console.log(` Token: ${TOKEN ? 'aktiv' : 'NICHT gesetzt (offen!)'}`); });