diff --git a/backend/package.json b/backend/package.json index c40a198..f7007f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,8 +10,6 @@ "multer": "^1.4.5-lts.1", "ws": "^8.17.1", "puppeteer-core": "^22.0.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", "mqtt": "^5.10.1" } } \ No newline at end of file diff --git a/backend/src/db.js b/backend/src/db.js index 0d1b582..bac62a8 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -613,13 +613,24 @@ db.exec(` ) `); -// MakerWorld-Zugangsdaten — pro Nutzer eigener Account (1:1), Passwort verschlüsselt +// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein +// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon +// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock) 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 + CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein +// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft +db.exec(` + CREATE TABLE IF NOT EXISTS makerworld_stats ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL ) `); diff --git a/backend/src/tools/makerworld/routes.js b/backend/src/tools/makerworld/routes.js index 9ff3853..8d8e5dc 100644 --- a/backend/src/tools/makerworld/routes.js +++ b/backend/src/tools/makerworld/routes.js @@ -1,84 +1,55 @@ const express = require('express'); -const fs = require('fs'); +const crypto = require('crypto'); const db = require('../../db'); -const { encrypt, decrypt } = require('../../crypto-helper'); const { authenticate } = require('../../middleware/auth'); const router = express.Router(); -// ── 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. +// ── MakerWorld Creator Center Statistik — Bookmarklet-Ansatz ───────────────── +// Nach mehreren gescheiterten Versuchen (Cloudflare blockt sowohl reinen +// fetch() als auch stealth-gepatchtes Puppeteer-Chromium, vermutlich per +// TLS-Fingerprinting) gibt es hier bewusst KEINE Server-seitige Automatisierung +// mehr. Stattdessen: der Nutzer öffnet die Statistikseite ganz normal in seinem +// eigenen, echten, eingeloggten Browser und klickt auf ein Lesezeichen +// ("Bookmarklet"). Das liest den bereits im Browser geladenen __NEXT_DATA__- +// Block direkt aus der Seite (kein Netzwerk-Request an makerworld.com nötig, +// keine Cloudflare-Prüfung möglich, da es der echte Browser des Nutzers ist) +// und schickt nur die Statistikdaten an diesen Endpunkt. // -// Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares -// 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 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. +// Authentifizierung: das Bookmarklet läuft im Kontext von makerworld.com +// (fremde Origin) und hat daher keinen Zugriff auf den DickenDock-Login-Token. +// Stattdessen bekommt jeder Nutzer ein eigenes, zufälliges Ingest-Token, das +// fest im Bookmarklet-Code steht — das ersetzt hier die reguläre Anmeldung. -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 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) -const activeBrowserByUser = new Map(); // userId -> zuletzt gestarteter Browser (für Force-Kill bei Timeout) +const cacheByUser = new Map(); // userId -> { data, fetchedAt } (Lazy-Load aus DB) function getCache(userId) { - if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null }); + if (!cacheByUser.has(userId)) { + const row = db.prepare('SELECT data_json, fetched_at FROM makerworld_stats WHERE user_id=?').get(userId); + cacheByUser.set(userId, row + ? { data: JSON.parse(row.data_json), fetchedAt: row.fetched_at } + : { data: null, fetchedAt: 0 }); + } 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 (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: row.username, password }; -} -function saveCredentials(userId, username, password) { +function persistCache(userId, data, fetchedAt) { db.prepare(` - INSERT OR REPLACE INTO makerworld_accounts (user_id, username, password_enc) + INSERT OR REPLACE INTO makerworld_stats (user_id, data_json, fetched_at) VALUES (?, ?, ?) - `).run(userId, username, encrypt(password)); + `).run(userId, JSON.stringify(data), fetchedAt); } -// ── __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 getOrCreateToken(userId) { + let row = db.prepare('SELECT token FROM makerworld_ingest_tokens WHERE user_id=?').get(userId); + if (!row) { + const token = crypto.randomBytes(24).toString('hex'); + db.prepare('INSERT INTO makerworld_ingest_tokens (user_id, token) VALUES (?, ?)').run(userId, token); + row = { token }; + } + return row.token; } -function mapStatisticalData(parsed) { - const statisticalData = parsed?.props?.pageProps?.statisticalData; +// Formt den vom Bookmarklet gesendeten statisticalData-Ausschnitt in unser Format +function mapStatisticalData(statisticalData) { if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)'); const s = statisticalData.summary; return { @@ -110,427 +81,57 @@ function mapStatisticalData(parsed) { }; } -// Diagnose/Fallback: Abruf OHNE Browser, nur reiner HTTP-fetch() mit dem -// übergebenen Cookie-String. Testet, ob ein reiner Cookie-Replay reicht, oder -// ob Cloudflare zusätzlich am TLS-Fingerprint der Verbindung erkennt, dass es -// kein "echter" Browser ist (dann hilft auch kein Puppeteer-Cookie-Inject). -async function tryRawFetch(userId, cookieString) { - try { - const res = await fetch(DATA_URL, { - headers: { - 'Cookie': cookieString, - 'User-Agent': USER_AGENT, - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'de,en-US;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate', - }, - }); - console.log(`[MakerWorld:${userId}] Raw-Fetch-Test: HTTP ${res.status}`); - if (!res.ok) return null; - const html = await res.text(); - const parsed = extractNextData(html); - console.log(`[MakerWorld:${userId}] Raw-Fetch-Test: __NEXT_DATA__ ${parsed ? 'gefunden ✓' : 'nicht gefunden (vermutlich Just-a-moment-Seite)'}`); - return parsed; - } catch (e) { - console.log(`[MakerWorld:${userId}] Raw-Fetch-Test fehlgeschlagen: ${e.message}`); - return null; - } -} +// ── Routes ──────────────────────────────────────────────────────────────────── -// ── Browser-Helfer ──────────────────────────────────────────────────────────── -// Nach einem abgebrochenen Vorgang (Container-Neustart mitten im Betrieb, -// Absturz o.ä.) kann Chromium veraltete Sperrdateien im Profil hinterlassen — -// dann hängt der nächste Start ewig, statt einen Fehler zu werfen. Vor jedem -// Start räumen wir das defensiv weg. -function cleanupStaleLocks(profileDir) { - for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) { - try { fs.rmSync(`${profileDir}/${name}`, { force: true }); } catch {} - } -} - -async function launchBrowser(userId) { - let puppeteer; - try { - // puppeteer-extra + Stealth-Plugin patcht die üblichen Merkmale, an denen - // Cloudflare & Co. headless Chromium erkennen (navigator.webdriver, fehlende - // Plugins/MimeTypes, iframe-contentWindow-Check, ...). Ohne das blieb die - // Seite bei "Just a moment..." hängen, auch nach langem Warten. - const { addExtra } = require('puppeteer-extra'); - const StealthPlugin = require('puppeteer-extra-plugin-stealth'); - const puppeteerCore = require('puppeteer-core'); - puppeteer = addExtra(puppeteerCore); - puppeteer.use(StealthPlugin()); - } catch { - throw new Error('puppeteer-core / puppeteer-extra nicht installiert'); - } - const profileDir = `${PROFILE_BASE_DIR}/${userId}`; - fs.mkdirSync(profileDir, { recursive: true }); - cleanupStaleLocks(profileDir); - const browser = await puppeteer.launch({ - executablePath: '/usr/bin/chromium-browser', - userDataDir: profileDir, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], - headless: true, - }); - activeBrowserByUser.set(userId, browser); - return browser; -} - -function forceKillBrowser(userId) { - const b = activeBrowserByUser.get(userId); - if (!b) return; - try { b.process()?.kill('SIGKILL'); } catch {} -} - -// Harte Zeitgrenze um einen kompletten Browser-Vorgang — falls irgendwo (Launch, -// Navigation, Screenshot, Close) etwas hängt, wird der Prozess hart beendet statt -// die komplette Warteschlange dieses Nutzers für immer zu blockieren. -function withHardTimeout(promise, ms, onTimeout) { - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(async () => { - try { await onTimeout?.(); } catch {} - reject(new Error(`Zeitüberschreitung nach ${Math.round(ms / 1000)}s — Vorgang wurde abgebrochen`)); - }, ms); - }); - return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); -} - -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|einen moment|überprüfung|einen augenblick|verifizierung ihres browsers/i.test(title); -} -// Speichert einen Screenshot + die aktuelle URL/Titel zur Fehlerdiagnose -async function saveDebugScreenshot(userId, page) { - const dir = `${PROFILE_BASE_DIR}/${userId}`; - fs.mkdirSync(dir, { recursive: true }); - await page.screenshot({ path: `${dir}/last-failure.png`, fullPage: false }); - fs.writeFileSync(`${dir}/last-failure.json`, JSON.stringify({ - url: page.url(), title: await page.title().catch(() => '?'), at: new Date().toISOString(), - })); -} -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 — 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) { - await saveDebugScreenshot(userId, page).catch(() => {}); - throw new Error(`Login-Formular nicht gefunden (Selektoren evtl. veraltet) — Seite: "${await page.title().catch(()=>'?')}" (${page.url()})`); - } - - 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)); - - 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(); - pendingLoginByUser.set(userId, { browser, page, codeField, startedAt }); - setTimeout(() => { - 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'); - err.needsCode = true; - throw err; - } - - await saveDebugScreenshot(userId, page).catch(() => {}); - throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout'); -} - -async function fetchStatsViaBrowserInner(userId, forceRefresh) { - const cache = getCache(userId); - if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) { - console.log(`[MakerWorld:${userId}] Cache-Treffer, kein Neuabruf (forceRefresh=false)`); - return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; - } - if (pendingLoginByUser.has(userId)) { - console.log(`[MakerWorld:${userId}] Noch ein offener Login-Vorgang (wartet auf Code) — Abruf übersprungen`); - return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true }; - } - - let browser; - let page; - try { - console.log(`[MakerWorld:${userId}] Abruf gestartet (forceRefresh=${forceRefresh})`); - browser = await launchBrowser(userId); - console.log(`[MakerWorld:${userId}] Browser gestartet, öffne Seite`); - 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 }); - console.log(`[MakerWorld:${userId}] Seite geladen: "${await page.title().catch(()=>'?')}" (${page.url()})`); - - // Bis zu 20 Sekunden Zeit geben, falls Cloudflare eine automatische - // Managed-Challenge zeigt (löst sich bei echtem Chromium meist selbst) - let cfWaited = 0; - for (let i = 0; i < 5 && await isCloudflareChallenge(page); i++) { - cfWaited += 4; - console.log(`[MakerWorld:${userId}] Cloudflare-Challenge erkannt, warte weiter (${cfWaited}s)...`); - await new Promise(r => setTimeout(r, 4000)); - } - - let parsed = await getNextDataFromPage(page); - console.log(`[MakerWorld:${userId}] __NEXT_DATA__ ${parsed ? 'gefunden' : 'NICHT gefunden'}`); - - if (!parsed && isLoginUrl(page.url())) { - console.log(`[MakerWorld:${userId}] Login-Seite erkannt (${page.url()}), versuche automatischen Login`); - parsed = await attemptLogin(userId, browser, page); // wirft ggf. needsCode-Error - } - - if (!parsed) { - const debugTitle = await page.title().catch(() => '?'); - const debugUrl = page.url(); - console.log(`[MakerWorld:${userId}] Fehlschlag — Seite: "${debugTitle}" (${debugUrl})`); - await saveDebugScreenshot(userId, page).catch(() => {}); - throw new Error(`__NEXT_DATA__ nicht gefunden — Seite: "${debugTitle}" (${debugUrl}). Debug-Screenshot verfügbar über GET /debug-screenshot`); - } - - const data = mapStatisticalData(parsed); - cache.data = data; cache.fetchedAt = Date.now(); cache.error = null; - await browser.close(); - console.log(`[MakerWorld:${userId}] Erfolgreich: ${data.downloads} Downloads, ${data.likes} Likes`); - return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; - } catch (e) { - if (e.needsCode) { - console.log(`[MakerWorld:${userId}] Code-Eingabe erforderlich, Browser bleibt offen`); - return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true }; - } - console.error(`[MakerWorld:${userId}] Fehler: ${e.message}`); - 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(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 } = pending; - 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; - 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 - return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true }; - } catch (e) { - await browser.close().catch(() => {}); - pendingLoginByUser.delete(userId); - cache.error = e.message; - return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }; - } -} - -async function injectCookiesInner(userId, cookieString) { - console.log(`[MakerWorld:${userId}] Cookie-Injection gestartet (${cookieString.length} Zeichen)`); - 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: '/' }; - }); - console.log(`[MakerWorld:${userId}] ${cookies.length} Cookies geparst: ${cookies.map(c=>c.name).join(', ')}`); - const browser = await launchBrowser(userId); - console.log(`[MakerWorld:${userId}] Browser für Cookie-Injection gestartet`); - try { - const page = await browser.newPage(); - await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch((e) => { - console.log(`[MakerWorld:${userId}] Navigation vor Cookie-Set fehlgeschlagen (ignoriert): ${e.message}`); - }); - await page.setCookie(...cookies); - console.log(`[MakerWorld:${userId}] Cookies gesetzt, schließe Browser`); - } finally { - await browser.close(); - } -} - -const fetchStatsViaBrowser = (userId, forceRefresh) => - serialized(userId, () => withHardTimeout(fetchStatsViaBrowserInner(userId, forceRefresh), 60000, () => forceKillBrowser(userId))); -const submitLoginCode = (userId, code) => - serialized(userId, () => withHardTimeout(submitLoginCodeInner(userId, code), 60000, () => forceKillBrowser(userId))); -const injectCookies = (userId, cookieString) => - serialized(userId, () => withHardTimeout(injectCookiesInner(userId, cookieString), 45000, () => forceKillBrowser(userId))); - -// ── Routes (alle nur "authenticate" — jeder verwaltet ausschließlich sich selbst) ── - -router.get('/stats', authenticate, async (req, res) => { - 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 }); - } - try { - const result = await fetchStatsViaBrowser(userId, false); - res.json({ configured: true, ...result }); - } catch (e) { - cache.error = e.message; - res.json({ configured: true, data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }); - } +// GET /stats – eigene, zuletzt per Bookmarklet empfangene Daten +router.get('/stats', authenticate, (req, res) => { + const cache = getCache(req.user.id); + res.json({ configured: !!cache.fetchedAt, data: cache.data, fetchedAt: cache.fetchedAt || null }); }); -router.post('/refresh', authenticate, async (req, res) => { - const userId = req.user.id; - const cache = getCache(userId); +// GET /ingest-token – eigenes (ggf. neu erzeugtes) Token + Server-Origin, für den Bookmarklet-Code +router.get('/ingest-token', authenticate, (req, res) => { + const token = getOrCreateToken(req.user.id); + res.json({ token, origin: `${req.protocol}://${req.get('host')}` }); +}); + +// POST /ingest-token/regenerate – altes Token ungültig machen, neues erzeugen +// (z.B. falls der Bookmarklet-Code mal versehentlich geteilt wurde) +router.post('/ingest-token/regenerate', authenticate, (req, res) => { + const token = crypto.randomBytes(24).toString('hex'); + db.prepare('INSERT OR REPLACE INTO makerworld_ingest_tokens (user_id, token) VALUES (?, ?)').run(req.user.id, token); + res.json({ token, origin: `${req.protocol}://${req.get('host')}` }); +}); + +// OPTIONS/POST /ingest – vom Bookmarklet aus makerworld.com aufgerufen (fremde +// Origin!) — daher bewusst OHNE "authenticate"-Middleware, das Ingest-Token im +// Body übernimmt die Rolle der Authentifizierung. CORS wird hier gezielt nur +// für diese eine Route geöffnet, nicht global für die App. +router.options('/ingest', (req, res) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + res.sendStatus(204); +}); + +router.post('/ingest', (req, res) => { + res.header('Access-Control-Allow-Origin', '*'); + const { token, statisticalData } = req.body || {}; + if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token fehlt' }); + + const row = db.prepare('SELECT user_id FROM makerworld_ingest_tokens WHERE token=?').get(token); + if (!row) return res.status(403).json({ error: 'Ungültiges Token' }); + try { - const result = await fetchStatsViaBrowser(userId, true); - res.json(result); + const data = mapStatisticalData(statisticalData); + const fetchedAt = Date.now(); + const cache = getCache(row.user_id); + cache.data = data; cache.fetchedAt = fetchedAt; + persistCache(row.user_id, data, fetchedAt); + console.log(`[MakerWorld:${row.user_id}] Daten per Bookmarklet empfangen: ${data.downloads} Downloads, ${data.likes} Likes`); + res.json({ ok: true }); } catch (e) { - cache.error = e.message; - res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }); - } -}); - -router.post('/verify-code', authenticate, async (req, res) => { - const { code } = req.body; - if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' }); - const userId = req.user.id; - const cache = getCache(userId); - try { - const result = await submitLoginCode(userId, code.trim()); - res.json(result); - } catch (e) { - cache.error = e.message; - res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }); - } -}); - -router.get('/login-status', authenticate, (req, res) => { - const userId = req.user.id; - const cache = getCache(userId); - const creds = getCredentials(userId); - const debugPath = `${PROFILE_BASE_DIR}/${userId}/last-failure.json`; - let debugInfo = null; - try { debugInfo = JSON.parse(fs.readFileSync(debugPath, 'utf8')); } catch {} - res.json({ - configured: !!creds, - username: creds?.username || null, - lastFetchedAt: cache.fetchedAt || null, - lastError: cache.error || null, - needsCode: pendingLoginByUser.has(userId), - debugScreenshot: debugInfo, - }); -}); - -// Letzter Fehl-Screenshot (Diagnose) — nur der eigene, als data:-URI (CSP erlaubt -// kein blob:/externes Laden, data: URIs sind bereits über img-src freigegeben) -router.get('/debug-screenshot', authenticate, (req, res) => { - const path = `${PROFILE_BASE_DIR}/${req.user.id}/last-failure.png`; - if (!fs.existsSync(path)) return res.status(404).json({ error: 'Kein Debug-Screenshot vorhanden' }); - const base64 = fs.readFileSync(path).toString('base64'); - res.json({ image: `data:image/png;base64,${base64}` }); -}); - -router.post('/credentials', authenticate, async (req, res) => { - const { username, password } = req.body; - if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); - const userId = req.user.id; - saveCredentials(userId, username.trim(), password); - const cache = getCache(userId); - try { - const result = await fetchStatsViaBrowser(userId, true); - res.json(result); - } catch (e) { - cache.error = e.message; - res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }); - } -}); - -// 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) => { - const { cookie } = req.body; - const userId = req.user.id; - console.log(`[MakerWorld:${userId}] POST /cookie empfangen (${cookie?.length || 0} Zeichen)`); - if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) { - return res.status(400).json({ error: 'Ungültiger Cookie-String' }); - } - const cache = getCache(userId); - - // Erst den günstigen Weg testen: reiner HTTP-fetch() ohne Browser - const rawParsed = await tryRawFetch(userId, cookie.trim()); - if (rawParsed) { - try { - const data = mapStatisticalData(rawParsed); - cache.data = data; cache.fetchedAt = Date.now(); cache.error = null; - console.log(`[MakerWorld:${userId}] Raw-Fetch erfolgreich, Puppeteer nicht nötig`); - return res.json({ data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false, method: 'raw-fetch' }); - } catch (e) { - console.log(`[MakerWorld:${userId}] Raw-Fetch lieferte Seite, aber Mapping fehlgeschlagen: ${e.message}`); - } - } - - // Fallback: über Puppeteer ins persistente Profil einspielen - try { - await injectCookies(userId, cookie.trim()); - const result = await fetchStatsViaBrowser(userId, true); - console.log(`[MakerWorld:${userId}] POST /cookie fertig (Puppeteer-Fallback): needsCode=${result.needsCode}, error=${result.error||'-'}`); - res.json({ ...result, method: 'puppeteer' }); - } catch (e) { - console.error(`[MakerWorld:${userId}] POST /cookie Exception: ${e.message}`); - res.status(500).json({ error: e.message }); + res.status(400).json({ error: e.message }); } }); diff --git a/frontend/src/tools/statistik.jsx b/frontend/src/tools/statistik.jsx index e6b0d8f..ccbc997 100644 --- a/frontend/src/tools/statistik.jsx +++ b/frontend/src/tools/statistik.jsx @@ -199,114 +199,38 @@ function OrdersInPeriod({ fromDate, toDate }) { // MakerWorld Creator-Center Statistik function MakerworldTab({ toast }) { - const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode } + const [state, setState] = useState(null); // { configured, data, fetchedAt } const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [tokenInfo, setTokenInfo] = useState(null); // { token, origin } + const [regenerating, setRegenerating] = 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 [debugImgUrl, setDebugImgUrl] = useState(null); - const [loadingScreenshot, setLoadingScreenshot] = useState(false); const load = () => { setLoading(true); api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false)); }; - const loadLoginStatus = () => { - api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{}); + const loadToken = () => { + api('/tools/makerworld/ingest-token').then(setTokenInfo).catch(()=>{}); }; - useEffect(() => { load(); loadLoginStatus(); }, []); + useEffect(() => { load(); loadToken(); }, []); - // 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); + const regenerateToken = async () => { + if (!window.confirm('Neues Bookmarklet erzeugen? Das alte funktioniert danach nicht mehr — du musst es neu ins Lesezeichen legen.')) return; + setRegenerating(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'); + const r = await api('/tools/makerworld/ingest-token/regenerate', { method:'POST' }); + setTokenInfo(r); + toast?.('Neues Bookmarklet erzeugt — bitte neu einrichten'); } catch(e) { toast?.(e.message,'error'); } - finally { setRefreshing(false); } + finally { setRegenerating(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 bookmarkletCode = tokenInfo ? `javascript:(function(){try{var el=document.getElementById('__NEXT_DATA__');if(!el){alert('MakerWorld: __NEXT_DATA__ nicht gefunden. Bist du auf der Statistikseite (/my/data-overview/model)?');return;}var nd=JSON.parse(el.textContent);var sd=nd.props&&nd.props.pageProps&&nd.props.pageProps.statisticalData;if(!sd||!sd.summary){alert('MakerWorld: Keine Statistikdaten auf dieser Seite gefunden.');return;}fetch('${tokenInfo.origin}/api/tools/makerworld/ingest',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${tokenInfo.token}',statisticalData:sd})}).then(function(r){return r.json();}).then(function(j){alert(j.ok?'DickenDock aktualisiert ✓':('Fehler: '+(j.error||'unbekannt')));}).catch(function(e){alert('Fehler: '+e.message);});}catch(e){alert('Fehler: '+e.message);}})();` : ''; - 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 viewDebugScreenshot = async () => { - setLoadingScreenshot(true); - try { - const r = await api('/tools/makerworld/debug-screenshot'); - if (!r.image) { toast?.('Kein Debug-Screenshot vorhanden','error'); return; } - setDebugImgUrl(r.image); // bereits eine data:-URI, CSP-konform (kein blob:) - } catch(e) { toast?.(e.message,'error'); } - finally { setLoadingScreenshot(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); } + const copyBookmarklet = async () => { + try { await navigator.clipboard.writeText(bookmarkletCode); toast?.('Bookmarklet-Code kopiert'); } + catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); } }; if (loading) return
Lädt…
; @@ -318,108 +242,54 @@ function MakerworldTab({ toast }) { 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.`; + if (min < 1440) return `vor ${Math.round(min/60)} Std.`; + return `vor ${Math.round(min/1440)} Tag(en)`; }; return (
- {/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */} - {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}}/> - -
+
+
EINRICHTUNG (EINMALIG)
+
+ Kein Login, kein Passwort nötig — nur ein Lesezeichen. Öffne die Seite{' '} + makerworld.com/de/my/data-overview/model{' '} + in deinem eigenen Browser (wo du eh eingeloggt bist), klicke dann auf das Lesezeichen — + die aktuell auf der Seite geladenen Zahlen werden direkt an DickenDock geschickt.
- )} - {/* Admin: Zugangsdaten */} - {!state?.needsCode && ( -
-
MEINE MAKERWORLD-ZUGANGSDATEN
- {loginStatus && ( -
- {loginStatus.configured - ? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}` - : 'Noch keine Zugangsdaten hinterlegt.'} - {loginStatus.lastError &&
⚠ {loginStatus.lastError}
} -
- )} -
- 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}))} - 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}}/> -
-
- - - - {loginStatus?.configured && ( - - )} -
- - {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. -
-