diff --git a/backend/src/tools/makerworld/routes.js b/backend/src/tools/makerworld/routes.js
new file mode 100644
index 0000000..0180a84
--- /dev/null
+++ b/backend/src/tools/makerworld/routes.js
@@ -0,0 +1,356 @@
+const express = require('express');
+const fs = require('fs');
+const db = require('../../db');
+const { encrypt, decrypt } = require('../../crypto-helper');
+const { authenticate } = require('../../middleware/auth');
+const router = express.Router();
+
+// ── MakerWorld Creator Center Statistik ──────────────────────────────────────
+// Strategie: echtes (headless) Chromium mit PERSISTENTEM Profil unter /data
+// (überlebt Container-Neustarts, da /data bereits als Volume gemountet ist).
+// Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares
+// JS-Challenge (cf_clearance) selbstständig — dafür ist kein manuelles
+// Cookie-Kopieren mehr nötig. Nur wenn die eigentliche Login-Session (nicht
+// nur Cloudflare) abgelaufen ist, wird automatisch neu eingeloggt; verlangt
+// MakerWorld dabei einen E-Mail-Verifizierungscode, wird der Vorgang pausiert
+// und wartet auf Eingabe des Codes über die App (POST /verify-code).
+//
+// HINWEIS: Die Login-Formular-Selektoren sind best-effort (Bambu Lab nutzt
+// einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup wir
+// nicht vorab kennen). Falls der automatische Login fehlschlägt, meldet die
+// App einen klaren Fehler statt stillschweigend hängen zu bleiben — dann bitte
+// einen HAR-Export des echten Login-Vorgangs schicken, um die Selektoren zu fixen.
+
+const DATA_URL = 'https://makerworld.com/de/my/data-overview/model';
+const PROFILE_DIR = '/data/makerworld-profile';
+const CACHE_TTL_MS = 30 * 60 * 1000; // 30 Minuten zwischen automatischen Neuabrufen
+const LOGIN_CODE_TIMEOUT_MS = 5 * 60 * 1000; // 5 Minuten Zeit, den E-Mail-Code einzugeben
+const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+
+const cache = { data: null, fetchedAt: 0, error: null };
+let pendingLogin = null; // { browser, page, codeField, startedAt } während auf E-Mail-Code gewartet wird
+
+// Alle Browser-Operationen seriell ausführen (gleiche Profil-Datei darf nicht
+// von zwei Chromium-Prozessen gleichzeitig verwendet werden)
+let opQueue = Promise.resolve();
+function serialized(fn) {
+ const run = opQueue.then(fn, fn);
+ opQueue = run.catch(() => {});
+ return run;
+}
+
+// ── Zugangsdaten ──────────────────────────────────────────────────────────────
+function getCredentials() {
+ const username = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_username'").get()?.value;
+ const passEnc = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_password_enc'").get()?.value;
+ if (!username || !passEnc) return null;
+ const password = decrypt(passEnc);
+ if (!password) return null;
+ return { username, password };
+}
+function saveCredentials(username, password) {
+ db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_username', ?)").run(username);
+ db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_password_enc', ?)").run(encrypt(password));
+}
+
+// ── __NEXT_DATA__ Extraktion (über Tag-Grenzen, kein Regex auf den JSON-Inhalt) ─
+function extractNextData(html) {
+ const marker = '', tagEnd);
+ if (scriptEnd === -1) return null;
+ try { return JSON.parse(html.slice(tagEnd, scriptEnd)); }
+ catch { return null; }
+}
+
+function mapStatisticalData(parsed) {
+ const statisticalData = parsed?.props?.pageProps?.statisticalData;
+ if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
+ const s = statisticalData.summary;
+ return {
+ downloads: s.download ?? 0,
+ prints: s.print ?? 0,
+ likes: s.like ?? 0,
+ collections: s.collect ?? 0,
+ boosts: s.boost ?? 0,
+ followers: s.follower ?? 0,
+ views: s.view ?? 0,
+ impressions: s.impression ?? 0,
+ creatorPoints: s.point ?? 0,
+ modelPoints: s.pointFromModel ?? 0,
+ instructionPoints: s.pointFromInst ?? 0,
+ models: (statisticalData.statisticalList || []).map(m => ({
+ id: m.designId,
+ title: m.title || m.titleTranslated || '',
+ cover: m.cover || '',
+ publishedAt: m.publishTime || '',
+ downloads: m.download ?? 0,
+ prints: m.print ?? 0,
+ likes: m.like ?? 0,
+ collections: m.collect ?? 0,
+ boosts: m.boost ?? 0,
+ views: m.view ?? 0,
+ impressions: m.impression ?? 0,
+ points: m.point ?? 0,
+ })),
+ };
+}
+
+// ── Browser-Helfer ────────────────────────────────────────────────────────────
+async function launchBrowser() {
+ let puppeteer;
+ try { puppeteer = require('puppeteer-core'); }
+ catch { throw new Error('puppeteer-core nicht installiert'); }
+ fs.mkdirSync(PROFILE_DIR, { recursive: true });
+ return puppeteer.launch({
+ executablePath: '/usr/bin/chromium-browser',
+ userDataDir: PROFILE_DIR,
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
+ headless: true,
+ });
+}
+
+async function getNextDataFromPage(page) {
+ const html = await page.content();
+ return extractNextData(html);
+}
+
+async function isCloudflareChallenge(page) {
+ const title = await page.title().catch(() => '');
+ return /just a moment|checking your browser|attention required/i.test(title);
+}
+function isLoginUrl(url) {
+ return /bambulab\.com\/[^/]*\/?sign-in|makerworld\.com\/[a-z]{2}\/sign-in/i.test(url || '');
+}
+async function findFirstSelector(page, selectors) {
+ for (const sel of selectors) {
+ const el = await page.$(sel).catch(() => null);
+ if (el) return sel;
+ }
+ return null;
+}
+
+// Best-effort Login-Formular ausfüllen. Wirft einen Error mit .needsCode=true,
+// falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — der
+// Browser/die Page bleiben dann bewusst OFFEN in `pendingLogin`.
+async function attemptLogin(browser, page) {
+ const creds = getCredentials();
+ if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten im Admin-Bereich hinterlegt');
+
+ const emailField = await findFirstSelector(page, [
+ 'input[type="email"]', 'input[name="account"]', 'input[name="username"]', 'input[type="text"]',
+ ]);
+ const pwField = await findFirstSelector(page, ['input[type="password"]']);
+ if (!emailField || !pwField) {
+ throw new Error('Login-Formular nicht gefunden (Selektoren evtl. veraltet) — bitte HAR der Login-Seite schicken, dann passe ich das an');
+ }
+
+ await page.type(emailField, creds.username, { delay: 25 });
+ await page.type(pwField, creds.password, { delay: 25 });
+
+ const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
+ if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
+ await new Promise(r => setTimeout(r, 3000));
+
+ // Direkt erfolgreich (bekanntes Gerät, kein Code nötig)?
+ let parsed = await getNextDataFromPage(page);
+ if (parsed) return parsed;
+
+ if (!page.url().includes('data-overview')) {
+ await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
+ parsed = await getNextDataFromPage(page);
+ if (parsed) return parsed;
+ }
+
+ const codeField = await findFirstSelector(page, [
+ 'input[name="code"]', 'input[autocomplete="one-time-code"]', 'input[type="tel"]',
+ ]);
+ if (codeField) {
+ const startedAt = Date.now();
+ pendingLogin = { browser, page, codeField, startedAt };
+ setTimeout(() => {
+ if (pendingLogin && pendingLogin.startedAt === startedAt) {
+ pendingLogin.browser.close().catch(() => {});
+ pendingLogin = null;
+ }
+ }, LOGIN_CODE_TIMEOUT_MS);
+ const err = new Error('Verifizierungscode per E-Mail erforderlich');
+ err.needsCode = true;
+ throw err;
+ }
+
+ throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout');
+}
+
+async function fetchStatsViaBrowserInner(forceRefresh) {
+ if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) {
+ return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
+ }
+ if (pendingLogin) {
+ return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
+ }
+
+ let browser;
+ try {
+ browser = await launchBrowser();
+ const page = await browser.newPage();
+ await page.setUserAgent(USER_AGENT);
+ await page.setViewport({ width: 1366, height: 900 });
+ await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 45000 });
+
+ for (let i = 0; i < 3 && await isCloudflareChallenge(page); i++) {
+ await new Promise(r => setTimeout(r, 4000));
+ }
+
+ let parsed = await getNextDataFromPage(page);
+
+ if (!parsed && isLoginUrl(page.url())) {
+ parsed = await attemptLogin(browser, page); // wirft ggf. needsCode-Error
+ }
+
+ if (!parsed) {
+ throw new Error('__NEXT_DATA__ nicht gefunden (weder Daten noch erkennbare Login-/Challenge-Seite) — Seitenlayout evtl. geändert');
+ }
+
+ const data = mapStatisticalData(parsed);
+ cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
+ await browser.close();
+ return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
+ } catch (e) {
+ if (e.needsCode) {
+ // Browser/Page bewusst NICHT schließen — liegt in pendingLogin bereit
+ return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
+ }
+ if (browser) await browser.close().catch(() => {});
+ cache.error = e.message;
+ return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
+ }
+}
+
+async function submitLoginCodeInner(code) {
+ if (!pendingLogin) {
+ return { data: cache.data, error: 'Kein offener Login-Vorgang', fetchedAt: cache.fetchedAt, needsCode: false };
+ }
+ const { browser, page, codeField } = pendingLogin;
+ try {
+ await page.type(codeField, code, { delay: 25 });
+ const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
+ if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
+ await new Promise(r => setTimeout(r, 3000));
+
+ let parsed = await getNextDataFromPage(page);
+ if (!parsed && !page.url().includes('data-overview')) {
+ await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
+ parsed = await getNextDataFromPage(page);
+ }
+
+ if (parsed) {
+ const data = mapStatisticalData(parsed);
+ cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
+ pendingLogin = null;
+ await browser.close();
+ return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
+ }
+
+ // Code vermutlich falsch — Vorgang bleibt offen für einen weiteren Versuch
+ // (solange das Zeitfenster noch läuft)
+ return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true };
+ } catch (e) {
+ await browser.close().catch(() => {});
+ pendingLogin = null;
+ cache.error = e.message;
+ return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
+ }
+}
+
+// Manuellen Cookie-String (Fallback) ins persistente Profil einspielen
+async function injectCookiesInner(cookieString) {
+ const cookies = cookieString.split(';').map(s => s.trim()).filter(Boolean).map(p => {
+ const idx = p.indexOf('=');
+ return { name: p.slice(0, idx), value: p.slice(idx + 1), domain: '.makerworld.com', path: '/' };
+ });
+ const browser = await launchBrowser();
+ try {
+ const page = await browser.newPage();
+ await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
+ await page.setCookie(...cookies);
+ } finally {
+ await browser.close();
+ }
+}
+
+const fetchStatsViaBrowser = (forceRefresh) => serialized(() => fetchStatsViaBrowserInner(forceRefresh));
+const submitLoginCode = (code) => serialized(() => submitLoginCodeInner(code));
+const injectCookies = (cookieString) => serialized(() => injectCookiesInner(cookieString));
+
+// ── Routes ────────────────────────────────────────────────────────────────────
+
+// GET /stats – für alle eingeloggten Nutzer (reine Anzeige)
+router.get('/stats', authenticate, async (req, res) => {
+ const configured = !!getCredentials() || !!cache.fetchedAt;
+ if (!configured) {
+ return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
+ }
+ const result = await fetchStatsViaBrowser(false);
+ res.json({ configured: true, ...result });
+});
+
+// POST /refresh – erzwungener Neu-Abruf (Admin)
+router.post('/refresh', authenticate, async (req, res) => {
+ if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
+ const result = await fetchStatsViaBrowser(true);
+ res.json(result);
+});
+
+// POST /verify-code – E-Mail-Verifizierungscode eingeben (Admin)
+router.post('/verify-code', authenticate, async (req, res) => {
+ if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
+ const { code } = req.body;
+ if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
+ const result = await submitLoginCode(code.trim());
+ res.json(result);
+});
+
+// GET /login-status – Diagnose für Admin (Passwort wird nie zurückgegeben)
+router.get('/login-status', authenticate, (req, res) => {
+ if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
+ const creds = getCredentials();
+ res.json({
+ configured: !!creds,
+ username: creds?.username || null,
+ lastFetchedAt: cache.fetchedAt || null,
+ lastError: cache.error || null,
+ needsCode: !!pendingLogin,
+ });
+});
+
+// POST /credentials – MakerWorld-Zugangsdaten speichern (verschlüsselt) + Testabruf (Admin)
+router.post('/credentials', authenticate, async (req, res) => {
+ if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
+ const { username, password } = req.body;
+ if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
+ saveCredentials(username.trim(), password);
+ const result = await fetchStatsViaBrowser(true);
+ res.json(result);
+});
+
+// POST /cookie – manueller Fallback: Cookie-String direkt ins Profil einspielen (Admin)
+router.post('/cookie', authenticate, async (req, res) => {
+ if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
+ const { cookie } = req.body;
+ if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) {
+ return res.status(400).json({ error: 'Ungültiger Cookie-String' });
+ }
+ try {
+ await injectCookies(cookie.trim());
+ const result = await fetchStatsViaBrowser(true);
+ res.json(result);
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+module.exports = router;
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7fdcdb8..2a09740 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3933,7 +3933,7 @@ export default function App() {