Files
dickendock/backend/src/tools/makerworld/routes.js

357 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<script id="__NEXT_DATA__"';
const markerIdx = html.indexOf(marker);
if (markerIdx === -1) return null;
const tagEnd = html.indexOf('>', markerIdx) + 1;
if (tagEnd === 0) return null;
const scriptEnd = html.indexOf('</script>', 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;