|
|
/**
|
|
|
* 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!)'}`);
|
|
|
});
|