diff --git a/backend/src/db.js b/backend/src/db.js index c2e667c..0d1b582 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -613,4 +613,14 @@ db.exec(` ) `); +// MakerWorld-Zugangsdaten — pro Nutzer eigener Account (1:1), Passwort verschlüsselt +db.exec(` + CREATE TABLE IF NOT EXISTS makerworld_accounts ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + password_enc TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + module.exports = db; diff --git a/backend/src/tools/makerworld/routes.js b/backend/src/tools/makerworld/routes.js index 0180a84..84995dd 100644 --- a/backend/src/tools/makerworld/routes.js +++ b/backend/src/tools/makerworld/routes.js @@ -5,52 +5,62 @@ 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). +// ── MakerWorld Creator Center Statistik — pro Nutzer eigener Account ──────── +// Jeder Nutzer hinterlegt seine EIGENEN MakerWorld-Zugangsdaten und sieht nur +// seine eigenen Creator-Center-Zahlen. Dafür bekommt jeder Nutzer ein eigenes, +// persistentes Chromium-Profil (eigene Cookies/Session, komplett getrennt von +// den anderen Nutzern), das unter /data liegt und Container-Neustarts übersteht. +// // 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). +// JS-Challenge (cf_clearance) selbstständig — kein manuelles Cookie-Kopieren +// im Normalfall. Nur wenn die eigentliche Login-Session abläuft und MakerWorld +// einen E-Mail-Verifizierungscode verlangt, pausiert der Vorgang 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. +// einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup nicht +// vorab bekannt ist). Schlägt der automatische Login fehl, gibt es eine klare +// Fehlermeldung statt eines stillen Hängers — 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 DATA_URL = 'https://makerworld.com/de/my/data-overview/model'; +const PROFILE_BASE_DIR = '/data/makerworld-profiles'; // + /{userId} 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 +const cacheByUser = new Map(); // userId -> { data, fetchedAt, error } +const pendingLoginByUser = new Map(); // userId -> { browser, page, codeField, startedAt } +const queueByUser = new Map(); // userId -> Promise (Serialisierung je Nutzer) -// 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(() => {}); +function getCache(userId) { + if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null }); + return cacheByUser.get(userId); +} + +// Jeder Nutzer bekommt seine eigene Operations-Queue — verschiedene Nutzer +// können parallel laufen, aber nie zwei Vorgänge gleichzeitig auf demselben +// Profil (Chromium sperrt das Profilverzeichnis sonst gegenseitig aus) +function serialized(userId, fn) { + const prev = queueByUser.get(userId) || Promise.resolve(); + const run = prev.then(fn, fn); + queueByUser.set(userId, 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); +// ── Zugangsdaten (pro Nutzer) ───────────────────────────────────────────────── +function getCredentials(userId) { + const row = db.prepare('SELECT username, password_enc FROM makerworld_accounts WHERE user_id=?').get(userId); + if (!row) return null; + const password = decrypt(row.password_enc); if (!password) return null; - return { username, password }; + return { username: row.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)); +function saveCredentials(userId, username, password) { + db.prepare(` + INSERT OR REPLACE INTO makerworld_accounts (user_id, username, password_enc) + VALUES (?, ?, ?) + `).run(userId, username, encrypt(password)); } // ── __NEXT_DATA__ Extraktion (über Tag-Grenzen, kein Regex auf den JSON-Inhalt) ─ @@ -100,14 +110,15 @@ function mapStatisticalData(parsed) { } // ── Browser-Helfer ──────────────────────────────────────────────────────────── -async function launchBrowser() { +async function launchBrowser(userId) { let puppeteer; try { puppeteer = require('puppeteer-core'); } catch { throw new Error('puppeteer-core nicht installiert'); } - fs.mkdirSync(PROFILE_DIR, { recursive: true }); + const profileDir = `${PROFILE_BASE_DIR}/${userId}`; + fs.mkdirSync(profileDir, { recursive: true }); return puppeteer.launch({ executablePath: '/usr/bin/chromium-browser', - userDataDir: PROFILE_DIR, + userDataDir: profileDir, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], headless: true, }); @@ -117,7 +128,6 @@ 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); @@ -134,18 +144,18 @@ async function findFirstSelector(page, selectors) { } // 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'); +// falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — Browser/ +// Page bleiben dann bewusst OFFEN in pendingLoginByUser. +async function attemptLogin(userId, browser, page) { + const creds = getCredentials(userId); + if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten 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'); + throw new Error('Login-Formular nicht gefunden (Selektoren evtl. veraltet)'); } await page.type(emailField, creds.username, { delay: 25 }); @@ -155,7 +165,6 @@ async function attemptLogin(browser, page) { 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; @@ -170,11 +179,12 @@ async function attemptLogin(browser, page) { ]); if (codeField) { const startedAt = Date.now(); - pendingLogin = { browser, page, codeField, startedAt }; + pendingLoginByUser.set(userId, { browser, page, codeField, startedAt }); setTimeout(() => { - if (pendingLogin && pendingLogin.startedAt === startedAt) { - pendingLogin.browser.close().catch(() => {}); - pendingLogin = null; + const p = pendingLoginByUser.get(userId); + if (p && p.startedAt === startedAt) { + p.browser.close().catch(() => {}); + pendingLoginByUser.delete(userId); } }, LOGIN_CODE_TIMEOUT_MS); const err = new Error('Verifizierungscode per E-Mail erforderlich'); @@ -185,17 +195,18 @@ async function attemptLogin(browser, page) { throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout'); } -async function fetchStatsViaBrowserInner(forceRefresh) { +async function fetchStatsViaBrowserInner(userId, forceRefresh) { + const cache = getCache(userId); if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) { return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; } - if (pendingLogin) { + if (pendingLoginByUser.has(userId)) { return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true }; } let browser; try { - browser = await launchBrowser(); + browser = await launchBrowser(userId); const page = await browser.newPage(); await page.setUserAgent(USER_AGENT); await page.setViewport({ width: 1366, height: 900 }); @@ -208,7 +219,7 @@ async function fetchStatsViaBrowserInner(forceRefresh) { let parsed = await getNextDataFromPage(page); if (!parsed && isLoginUrl(page.url())) { - parsed = await attemptLogin(browser, page); // wirft ggf. needsCode-Error + parsed = await attemptLogin(userId, browser, page); // wirft ggf. needsCode-Error } if (!parsed) { @@ -221,7 +232,6 @@ async function fetchStatsViaBrowserInner(forceRefresh) { 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(() => {}); @@ -230,11 +240,13 @@ async function fetchStatsViaBrowserInner(forceRefresh) { } } -async function submitLoginCodeInner(code) { - if (!pendingLogin) { +async function submitLoginCodeInner(userId, code) { + const cache = getCache(userId); + const pending = pendingLoginByUser.get(userId); + if (!pending) { return { data: cache.data, error: 'Kein offener Login-Vorgang', fetchedAt: cache.fetchedAt, needsCode: false }; } - const { browser, page, codeField } = pendingLogin; + const { browser, page, codeField } = pending; try { await page.type(codeField, code, { delay: 25 }); const submitBtn = await findFirstSelector(page, ['button[type="submit"]']); @@ -250,29 +262,27 @@ async function submitLoginCodeInner(code) { if (parsed) { const data = mapStatisticalData(parsed); cache.data = data; cache.fetchedAt = Date.now(); cache.error = null; - pendingLogin = null; + pendingLoginByUser.delete(userId); 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; + pendingLoginByUser.delete(userId); 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) { +async function injectCookiesInner(userId, 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(); + const browser = await launchBrowser(userId); try { const page = await browser.newPage(); await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}); @@ -282,71 +292,71 @@ async function injectCookiesInner(cookieString) { } } -const fetchStatsViaBrowser = (forceRefresh) => serialized(() => fetchStatsViaBrowserInner(forceRefresh)); -const submitLoginCode = (code) => serialized(() => submitLoginCodeInner(code)); -const injectCookies = (cookieString) => serialized(() => injectCookiesInner(cookieString)); +const fetchStatsViaBrowser = (userId, forceRefresh) => serialized(userId, () => fetchStatsViaBrowserInner(userId, forceRefresh)); +const submitLoginCode = (userId, code) => serialized(userId, () => submitLoginCodeInner(userId, code)); +const injectCookies = (userId, cookieString) => serialized(userId, () => injectCookiesInner(userId, cookieString)); -// ── Routes ──────────────────────────────────────────────────────────────────── +// ── Routes (alle nur "authenticate" — jeder verwaltet ausschließlich sich selbst) ── -// GET /stats – für alle eingeloggten Nutzer (reine Anzeige) router.get('/stats', authenticate, async (req, res) => { - const configured = !!getCredentials() || !!cache.fetchedAt; + const userId = req.user.id; + const cache = getCache(userId); + const configured = !!getCredentials(userId) || !!cache.fetchedAt; if (!configured) { return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false }); } - const result = await fetchStatsViaBrowser(false); + const result = await fetchStatsViaBrowser(userId, 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); + const result = await fetchStatsViaBrowser(req.user.id, 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()); + const result = await submitLoginCode(req.user.id, 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(); + const userId = req.user.id; + const cache = getCache(userId); + const creds = getCredentials(userId); res.json({ configured: !!creds, username: creds?.username || null, lastFetchedAt: cache.fetchedAt || null, lastError: cache.error || null, - needsCode: !!pendingLogin, + needsCode: pendingLoginByUser.has(userId), }); }); -// 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); + saveCredentials(req.user.id, username.trim(), password); + const result = await fetchStatsViaBrowser(req.user.id, true); res.json(result); }); -// POST /cookie – manueller Fallback: Cookie-String direkt ins Profil einspielen (Admin) +// Eigene Zugangsdaten entfernen (Profil/Cookies bleiben unangetastet liegen) +router.delete('/credentials', authenticate, (req, res) => { + db.prepare('DELETE FROM makerworld_accounts WHERE user_id=?').run(req.user.id); + res.json({ ok: true }); +}); + +// Manueller Fallback: Cookie-String direkt ins eigene Profil einspielen 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); + await injectCookies(req.user.id, cookie.trim()); + const result = await fetchStatsViaBrowser(req.user.id, true); res.json(result); } catch (e) { res.status(500).json({ error: e.message }); diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx index 51054cf..cb31424 100644 --- a/frontend/src/tools/statistik.jsx +++ b/frontend/src/tools/statistik.jsx @@ -198,7 +198,7 @@ function OrdersInPeriod({ fromDate, toDate }) { } // MakerWorld Creator-Center Statistik -function MakerworldTab({ isAdmin, toast }) { +function MakerworldTab({ toast }) { const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode } const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -219,7 +219,6 @@ function MakerworldTab({ isAdmin, toast }) { api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false)); }; const loadLoginStatus = () => { - if (!isAdmin) return; api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{}); }; @@ -260,6 +259,15 @@ function MakerworldTab({ isAdmin, toast }) { finally { setSavingCreds(false); } }; + const removeCredentials = async () => { + try { + await api('/tools/makerworld/credentials', { method:'DELETE' }); + toast?.('Zugangsdaten entfernt'); + setState({ configured:false, data:null, error:null, fetchedAt:null, needsCode:false }); + loadLoginStatus(); + } catch(e) { toast?.(e.message,'error'); } + }; + const submitCode = async () => { if (!codeInput.trim()) return; setSubmittingCode(true); @@ -303,7 +311,7 @@ function MakerworldTab({ isAdmin, toast }) { return (
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */} - {isAdmin && state?.needsCode && ( + {state?.needsCode && (
📧 VERIFIZIERUNGSCODE ERFORDERLICH
@@ -323,9 +331,9 @@ function MakerworldTab({ isAdmin, toast }) { )} {/* Admin: Zugangsdaten */} - {isAdmin && !state?.needsCode && ( + {!state?.needsCode && (
-
MAKERWORLD-LOGIN (ADMIN)
+
MEINE MAKERWORLD-ZUGANGSDATEN
{loginStatus && (
{loginStatus.configured @@ -335,9 +343,10 @@ function MakerworldTab({ isAdmin, toast }) {
)}
- 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. + Wird verschlüsselt gespeichert und nur für deinen eigenen Abruf genutzt — andere Nutzer sehen + weder deine Zugangsdaten noch deine Zahlen. 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}))} @@ -358,6 +367,11 @@ function MakerworldTab({ isAdmin, toast }) { + {loginStatus?.configured && ( + + )}
{showCookieFallback && ( @@ -379,14 +393,6 @@ function MakerworldTab({ isAdmin, toast }) {
)} - {!state?.configured && !isAdmin && ( -
-
- MakerWorld-Anbindung ist noch nicht eingerichtet. -
-
- )} - {state?.error && d && (
@@ -754,7 +760,7 @@ export default function Statistik({ mobile, user, toast }) { {/* ══ MAKERWORLD ══ */} {tab==='makerworld' && ( - + )}
);