feat: MakerWorld-Anbindung pro Nutzer (eigener Login, eigenes Profil, eigene Daten)

This commit is contained in:
2026-07-11 02:16:20 +02:00
parent 24b40d1284
commit 9b947aeb89
3 changed files with 129 additions and 103 deletions

View File

@@ -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;

View File

@@ -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 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 });

View File

@@ -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 (
<div>
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
{isAdmin && state?.needsCode && (
{state?.needsCode && (
<div style={{...S.card,borderColor:'rgba(255,230,109,0.4)',background:'rgba(255,230,109,0.08)'}}>
<div style={{...S.head,color:'#ffe66d'}}>📧 VERIFIZIERUNGSCODE ERFORDERLICH</div>
<div style={{...S.sub,marginBottom:8}}>
@@ -323,9 +331,9 @@ function MakerworldTab({ isAdmin, toast }) {
)}
{/* Admin: Zugangsdaten */}
{isAdmin && !state?.needsCode && (
{!state?.needsCode && (
<div style={S.card}>
<div style={S.head}>MAKERWORLD-LOGIN (ADMIN)</div>
<div style={S.head}>MEINE MAKERWORLD-ZUGANGSDATEN</div>
{loginStatus && (
<div style={{...S.sub,marginBottom:10}}>
{loginStatus.configured
@@ -335,9 +343,10 @@ function MakerworldTab({ isAdmin, toast }) {
</div>
)}
<div style={{...S.sub,marginBottom:8}}>
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.
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}>
<input value={credForm.username} onChange={e=>setCredForm(p=>({...p,username:e.target.value}))}
@@ -358,6 +367,11 @@ function MakerworldTab({ isAdmin, toast }) {
<button onClick={()=>setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
</button>
{loginStatus?.configured && (
<button onClick={removeCredentials} style={{...S.btn('#f87171')}}>
🗑 Zugangsdaten entfernen
</button>
)}
</div>
{showCookieFallback && (
@@ -379,14 +393,6 @@ function MakerworldTab({ isAdmin, toast }) {
</div>
)}
{!state?.configured && !isAdmin && (
<div style={S.card}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
MakerWorld-Anbindung ist noch nicht eingerichtet.
</div>
</div>
)}
{state?.error && d && (
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}>
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>
@@ -754,7 +760,7 @@ export default function Statistik({ mobile, user, toast }) {
{/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && (
<MakerworldTab isAdmin={user?.role==='admin'} toast={toast}/>
<MakerworldTab toast={toast}/>
)}
</div>
);