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() {
{active==='dashboard' && { setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>} {active==='admin' && } - {activeTool && setMediaInitTab(null)} : {})}/>} + {activeTool && setMediaInitTab(null)} : {})}/>}
{mobile && ( diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx index 0ef72b2..51054cf 100644 --- a/frontend/src/tools/statistik.jsx +++ b/frontend/src/tools/statistik.jsx @@ -197,7 +197,254 @@ function OrdersInPeriod({ fromDate, toDate }) { ); } -export default function Statistik({ mobile }) { +// MakerWorld Creator-Center Statistik +function MakerworldTab({ isAdmin, toast }) { + const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode } + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [showModels, setShowModels] = useState(false); + const [loginStatus, setLoginStatus] = useState(null); + + const [credForm, setCredForm] = useState({ username:'', password:'' }); + const [savingCreds, setSavingCreds] = useState(false); + const [codeInput, setCodeInput] = useState(''); + const [submittingCode, setSubmittingCode] = useState(false); + + const [cookieInput, setCookieInput] = useState(''); + const [savingCookie, setSavingCookie] = useState(false); + const [showCookieFallback, setShowCookieFallback] = useState(false); + + const load = () => { + setLoading(true); + api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false)); + }; + const loadLoginStatus = () => { + if (!isAdmin) return; + api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{}); + }; + + useEffect(() => { load(); loadLoginStatus(); }, []); + + // Solange auf einen Code gewartet wird, alle paar Sekunden pollen + useEffect(() => { + if (!state?.needsCode) return; + const iv = setInterval(() => { load(); loadLoginStatus(); }, 5000); + return () => clearInterval(iv); + }, [state?.needsCode]); + + const refresh = async () => { + setRefreshing(true); + try { + const r = await api('/tools/makerworld/refresh', { method:'POST' }); + setState(p => ({ ...p, ...r })); + loadLoginStatus(); + if (r.needsCode) toast?.('MakerWorld verlangt einen E-Mail-Verifizierungscode'); + else if (r.error) toast?.(r.error,'error'); + else toast?.('Aktualisiert'); + } catch(e) { toast?.(e.message,'error'); } + finally { setRefreshing(false); } + }; + + const saveCredentials = async () => { + if (!credForm.username.trim() || !credForm.password) return; + setSavingCreds(true); + try { + const r = await api('/tools/makerworld/credentials', { body:credForm }); + setCredForm({ username:'', password:'' }); + setState(p => ({ ...p, ...r, configured:true })); + loadLoginStatus(); + if (r.needsCode) toast?.('Zugangsdaten gespeichert — E-Mail-Code erforderlich'); + else if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error'); + else toast?.('Zugangsdaten gespeichert, Abruf erfolgreich'); + } catch(e) { toast?.(e.message,'error'); } + finally { setSavingCreds(false); } + }; + + const submitCode = async () => { + if (!codeInput.trim()) return; + setSubmittingCode(true); + try { + const r = await api('/tools/makerworld/verify-code', { body:{ code:codeInput.trim() } }); + setState(p => ({ ...p, ...r })); + loadLoginStatus(); + if (r.needsCode) toast?.(r.error || 'Code nicht akzeptiert, nochmal versuchen','error'); + else if (r.error) toast?.(r.error,'error'); + else { toast?.('Login erfolgreich'); setCodeInput(''); } + } catch(e) { toast?.(e.message,'error'); } + finally { setSubmittingCode(false); } + }; + + const saveCookie = async () => { + if (!cookieInput.trim()) return; + setSavingCookie(true); + try { + const r = await api('/tools/makerworld/cookie', { body:{ cookie: cookieInput } }); + setCookieInput(''); + setState(p => ({ ...p, ...r, configured:true })); + if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error'); + else toast?.('Cookie übernommen, Abruf erfolgreich'); + loadLoginStatus(); + } catch(e) { toast?.(e.message,'error'); } + finally { setSavingCookie(false); } + }; + + if (loading) return
Lädt…
; + + const d = state?.data; + const fmtInt = v => (v ?? 0).toLocaleString('de-DE'); + const fmtAgo = ts => { + if (!ts) return '—'; + const min = Math.round((Date.now()-ts)/60000); + if (min < 1) return 'gerade eben'; + if (min < 60) return `vor ${min} Min.`; + return `vor ${Math.round(min/60)} Std.`; + }; + + return ( +
+ {/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */} + {isAdmin && state?.needsCode && ( +
+
📧 VERIFIZIERUNGSCODE ERFORDERLICH
+
+ MakerWorld hat einen Code an deine E-Mail-Adresse geschickt. Bitte hier eintragen + (5 Minuten Zeit, danach muss der Login-Versuch neu gestartet werden). +
+
+ setCodeInput(e.target.value)} + onKeyDown={e=>e.key==='Enter'&&submitCode()} + placeholder="Code aus E-Mail" style={{...S.inp,flex:1}}/> + +
+
+ )} + + {/* Admin: Zugangsdaten */} + {isAdmin && !state?.needsCode && ( +
+
MAKERWORLD-LOGIN (ADMIN)
+ {loginStatus && ( +
+ {loginStatus.configured + ? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}` + : 'Noch keine Zugangsdaten hinterlegt.'} + {loginStatus.lastError &&
⚠ {loginStatus.lastError}
} +
+ )} +
+ Wird verschlüsselt gespeichert. Ein echter Browser mit dauerhaftem Profil hält die Session + am Laufen — bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch, erscheint + hier automatisch die Code-Eingabe. +
+
+ setCredForm(p=>({...p,username:e.target.value}))} + placeholder="E-Mail / Benutzername" style={{...S.inp,flex:1,minWidth:160}}/> + setCredForm(p=>({...p,password:e.target.value}))} + onKeyDown={e=>e.key==='Enter'&&saveCredentials()} + placeholder="Passwort" style={{...S.inp,flex:1,minWidth:160}}/> +
+
+ + + +
+ + {showCookieFallback && ( +
+
+ Nur falls der automatische Login nicht funktioniert: kompletten Cookie-Header aus einem + eingeloggten Browser-Tab kopieren (Entwicklertools → Netzwerk → Request an + /de/my/data-overview/model → "Cookie"-Header) und hier einfügen. +
+