You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

152 lines
5.7 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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

Powered by TurnKey Linux.