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;