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; module.exports = db;

View File

@@ -5,52 +5,62 @@ const { encrypt, decrypt } = require('../../crypto-helper');
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
// ── MakerWorld Creator Center Statistik ────────────────────────────────────── // ── MakerWorld Creator Center Statistik — pro Nutzer eigener Account ────────
// Strategie: echtes (headless) Chromium mit PERSISTENTEM Profil unter /data // Jeder Nutzer hinterlegt seine EIGENEN MakerWorld-Zugangsdaten und sieht nur
// (überlebt Container-Neustarts, da /data bereits als Volume gemountet ist). // 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 // Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares
// JS-Challenge (cf_clearance) selbstständig — dafür ist kein manuelles // JS-Challenge (cf_clearance) selbstständig — kein manuelles Cookie-Kopieren
// Cookie-Kopieren mehr nötig. Nur wenn die eigentliche Login-Session (nicht // im Normalfall. Nur wenn die eigentliche Login-Session abläuft und MakerWorld
// nur Cloudflare) abgelaufen ist, wird automatisch neu eingeloggt; verlangt // einen E-Mail-Verifizierungscode verlangt, pausiert der Vorgang und wartet auf
// MakerWorld dabei einen E-Mail-Verifizierungscode, wird der Vorgang pausiert // Eingabe des Codes über die App (POST /verify-code).
// und wartet auf Eingabe des Codes über die App (POST /verify-code).
// //
// HINWEIS: Die Login-Formular-Selektoren sind best-effort (Bambu Lab nutzt // HINWEIS: Die Login-Formular-Selektoren sind best-effort (Bambu Lab nutzt
// einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup wir // einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup nicht
// nicht vorab kennen). Falls der automatische Login fehlschlägt, meldet die // vorab bekannt ist). Schlägt der automatische Login fehl, gibt es eine klare
// App einen klaren Fehler statt stillschweigend hängen zu bleiben — dann bitte // Fehlermeldung statt eines stillen Hängers — dann bitte einen HAR-Export
// einen HAR-Export des echten Login-Vorgangs schicken, um die Selektoren zu fixen. // des echten Login-Vorgangs schicken, um die Selektoren zu fixen.
const DATA_URL = 'https://makerworld.com/de/my/data-overview/model'; 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 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 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 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 }; const cacheByUser = new Map(); // userId -> { data, fetchedAt, error }
let pendingLogin = null; // { browser, page, codeField, startedAt } während auf E-Mail-Code gewartet wird 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 function getCache(userId) {
// von zwei Chromium-Prozessen gleichzeitig verwendet werden) if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null });
let opQueue = Promise.resolve(); return cacheByUser.get(userId);
function serialized(fn) { }
const run = opQueue.then(fn, fn);
opQueue = run.catch(() => {}); // 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; return run;
} }
// ── Zugangsdaten ────────────────────────────────────────────────────────────── // ── Zugangsdaten (pro Nutzer) ─────────────────────────────────────────────────
function getCredentials() { function getCredentials(userId) {
const username = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_username'").get()?.value; const row = db.prepare('SELECT username, password_enc FROM makerworld_accounts WHERE user_id=?').get(userId);
const passEnc = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_password_enc'").get()?.value; if (!row) return null;
if (!username || !passEnc) return null; const password = decrypt(row.password_enc);
const password = decrypt(passEnc);
if (!password) return null; if (!password) return null;
return { username, password }; return { username: row.username, password };
} }
function saveCredentials(username, password) { function saveCredentials(userId, username, password) {
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_username', ?)").run(username); db.prepare(`
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_password_enc', ?)").run(encrypt(password)); 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) ─ // ── __NEXT_DATA__ Extraktion (über Tag-Grenzen, kein Regex auf den JSON-Inhalt) ─
@@ -100,14 +110,15 @@ function mapStatisticalData(parsed) {
} }
// ── Browser-Helfer ──────────────────────────────────────────────────────────── // ── Browser-Helfer ────────────────────────────────────────────────────────────
async function launchBrowser() { async function launchBrowser(userId) {
let puppeteer; let puppeteer;
try { puppeteer = require('puppeteer-core'); } try { puppeteer = require('puppeteer-core'); }
catch { throw new Error('puppeteer-core nicht installiert'); } 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({ return puppeteer.launch({
executablePath: '/usr/bin/chromium-browser', executablePath: '/usr/bin/chromium-browser',
userDataDir: PROFILE_DIR, userDataDir: profileDir,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
headless: true, headless: true,
}); });
@@ -117,7 +128,6 @@ async function getNextDataFromPage(page) {
const html = await page.content(); const html = await page.content();
return extractNextData(html); return extractNextData(html);
} }
async function isCloudflareChallenge(page) { async function isCloudflareChallenge(page) {
const title = await page.title().catch(() => ''); const title = await page.title().catch(() => '');
return /just a moment|checking your browser|attention required/i.test(title); 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, // Best-effort Login-Formular ausfüllen. Wirft einen Error mit .needsCode=true,
// falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — der // falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — Browser/
// Browser/die Page bleiben dann bewusst OFFEN in `pendingLogin`. // Page bleiben dann bewusst OFFEN in pendingLoginByUser.
async function attemptLogin(browser, page) { async function attemptLogin(userId, browser, page) {
const creds = getCredentials(); const creds = getCredentials(userId);
if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten im Admin-Bereich hinterlegt'); if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten hinterlegt');
const emailField = await findFirstSelector(page, [ const emailField = await findFirstSelector(page, [
'input[type="email"]', 'input[name="account"]', 'input[name="username"]', 'input[type="text"]', 'input[type="email"]', 'input[name="account"]', 'input[name="username"]', 'input[type="text"]',
]); ]);
const pwField = await findFirstSelector(page, ['input[type="password"]']); const pwField = await findFirstSelector(page, ['input[type="password"]']);
if (!emailField || !pwField) { 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 }); 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'); if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
await new Promise(r => setTimeout(r, 3000)); await new Promise(r => setTimeout(r, 3000));
// Direkt erfolgreich (bekanntes Gerät, kein Code nötig)?
let parsed = await getNextDataFromPage(page); let parsed = await getNextDataFromPage(page);
if (parsed) return parsed; if (parsed) return parsed;
@@ -170,11 +179,12 @@ async function attemptLogin(browser, page) {
]); ]);
if (codeField) { if (codeField) {
const startedAt = Date.now(); const startedAt = Date.now();
pendingLogin = { browser, page, codeField, startedAt }; pendingLoginByUser.set(userId, { browser, page, codeField, startedAt });
setTimeout(() => { setTimeout(() => {
if (pendingLogin && pendingLogin.startedAt === startedAt) { const p = pendingLoginByUser.get(userId);
pendingLogin.browser.close().catch(() => {}); if (p && p.startedAt === startedAt) {
pendingLogin = null; p.browser.close().catch(() => {});
pendingLoginByUser.delete(userId);
} }
}, LOGIN_CODE_TIMEOUT_MS); }, LOGIN_CODE_TIMEOUT_MS);
const err = new Error('Verifizierungscode per E-Mail erforderlich'); 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'); 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) { if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) {
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; 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 }; return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
} }
let browser; let browser;
try { try {
browser = await launchBrowser(); browser = await launchBrowser(userId);
const page = await browser.newPage(); const page = await browser.newPage();
await page.setUserAgent(USER_AGENT); await page.setUserAgent(USER_AGENT);
await page.setViewport({ width: 1366, height: 900 }); await page.setViewport({ width: 1366, height: 900 });
@@ -208,7 +219,7 @@ async function fetchStatsViaBrowserInner(forceRefresh) {
let parsed = await getNextDataFromPage(page); let parsed = await getNextDataFromPage(page);
if (!parsed && isLoginUrl(page.url())) { 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) { if (!parsed) {
@@ -221,7 +232,6 @@ async function fetchStatsViaBrowserInner(forceRefresh) {
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
} catch (e) { } catch (e) {
if (e.needsCode) { if (e.needsCode) {
// Browser/Page bewusst NICHT schließen — liegt in pendingLogin bereit
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true }; return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
} }
if (browser) await browser.close().catch(() => {}); if (browser) await browser.close().catch(() => {});
@@ -230,11 +240,13 @@ async function fetchStatsViaBrowserInner(forceRefresh) {
} }
} }
async function submitLoginCodeInner(code) { async function submitLoginCodeInner(userId, code) {
if (!pendingLogin) { 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 }; 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 { try {
await page.type(codeField, code, { delay: 25 }); await page.type(codeField, code, { delay: 25 });
const submitBtn = await findFirstSelector(page, ['button[type="submit"]']); const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
@@ -250,29 +262,27 @@ async function submitLoginCodeInner(code) {
if (parsed) { if (parsed) {
const data = mapStatisticalData(parsed); const data = mapStatisticalData(parsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null; cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
pendingLogin = null; pendingLoginByUser.delete(userId);
await browser.close(); await browser.close();
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false }; return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
} }
// Code vermutlich falsch — Vorgang bleibt offen für einen weiteren Versuch // 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 }; return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true };
} catch (e) { } catch (e) {
await browser.close().catch(() => {}); await browser.close().catch(() => {});
pendingLogin = null; pendingLoginByUser.delete(userId);
cache.error = e.message; cache.error = e.message;
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false }; return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
} }
} }
// Manuellen Cookie-String (Fallback) ins persistente Profil einspielen async function injectCookiesInner(userId, cookieString) {
async function injectCookiesInner(cookieString) {
const cookies = cookieString.split(';').map(s => s.trim()).filter(Boolean).map(p => { const cookies = cookieString.split(';').map(s => s.trim()).filter(Boolean).map(p => {
const idx = p.indexOf('='); const idx = p.indexOf('=');
return { name: p.slice(0, idx), value: p.slice(idx + 1), domain: '.makerworld.com', path: '/' }; 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 { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}); 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 fetchStatsViaBrowser = (userId, forceRefresh) => serialized(userId, () => fetchStatsViaBrowserInner(userId, forceRefresh));
const submitLoginCode = (code) => serialized(() => submitLoginCodeInner(code)); const submitLoginCode = (userId, code) => serialized(userId, () => submitLoginCodeInner(userId, code));
const injectCookies = (cookieString) => serialized(() => injectCookiesInner(cookieString)); 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) => { 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) { if (!configured) {
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false }); 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 }); res.json({ configured: true, ...result });
}); });
// POST /refresh erzwungener Neu-Abruf (Admin)
router.post('/refresh', authenticate, async (req, res) => { router.post('/refresh', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); const result = await fetchStatsViaBrowser(req.user.id, true);
const result = await fetchStatsViaBrowser(true);
res.json(result); res.json(result);
}); });
// POST /verify-code E-Mail-Verifizierungscode eingeben (Admin)
router.post('/verify-code', authenticate, async (req, res) => { 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; const { code } = req.body;
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' }); 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); res.json(result);
}); });
// GET /login-status Diagnose für Admin (Passwort wird nie zurückgegeben)
router.get('/login-status', authenticate, (req, res) => { router.get('/login-status', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); const userId = req.user.id;
const creds = getCredentials(); const cache = getCache(userId);
const creds = getCredentials(userId);
res.json({ res.json({
configured: !!creds, configured: !!creds,
username: creds?.username || null, username: creds?.username || null,
lastFetchedAt: cache.fetchedAt || null, lastFetchedAt: cache.fetchedAt || null,
lastError: cache.error || 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) => { 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; const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
saveCredentials(username.trim(), password); saveCredentials(req.user.id, username.trim(), password);
const result = await fetchStatsViaBrowser(true); const result = await fetchStatsViaBrowser(req.user.id, true);
res.json(result); 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) => { router.post('/cookie', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const { cookie } = req.body; const { cookie } = req.body;
if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) { if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) {
return res.status(400).json({ error: 'Ungültiger Cookie-String' }); return res.status(400).json({ error: 'Ungültiger Cookie-String' });
} }
try { try {
await injectCookies(cookie.trim()); await injectCookies(req.user.id, cookie.trim());
const result = await fetchStatsViaBrowser(true); const result = await fetchStatsViaBrowser(req.user.id, true);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });

View File

@@ -198,7 +198,7 @@ function OrdersInPeriod({ fromDate, toDate }) {
} }
// MakerWorld Creator-Center Statistik // MakerWorld Creator-Center Statistik
function MakerworldTab({ isAdmin, toast }) { function MakerworldTab({ toast }) {
const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode } const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode }
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -219,7 +219,6 @@ function MakerworldTab({ isAdmin, toast }) {
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false)); api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
}; };
const loadLoginStatus = () => { const loadLoginStatus = () => {
if (!isAdmin) return;
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{}); api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
}; };
@@ -260,6 +259,15 @@ function MakerworldTab({ isAdmin, toast }) {
finally { setSavingCreds(false); } 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 () => { const submitCode = async () => {
if (!codeInput.trim()) return; if (!codeInput.trim()) return;
setSubmittingCode(true); setSubmittingCode(true);
@@ -303,7 +311,7 @@ function MakerworldTab({ isAdmin, toast }) {
return ( return (
<div> <div>
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */} {/* 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.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.head,color:'#ffe66d'}}>📧 VERIFIZIERUNGSCODE ERFORDERLICH</div>
<div style={{...S.sub,marginBottom:8}}> <div style={{...S.sub,marginBottom:8}}>
@@ -323,9 +331,9 @@ function MakerworldTab({ isAdmin, toast }) {
)} )}
{/* Admin: Zugangsdaten */} {/* Admin: Zugangsdaten */}
{isAdmin && !state?.needsCode && ( {!state?.needsCode && (
<div style={S.card}> <div style={S.card}>
<div style={S.head}>MAKERWORLD-LOGIN (ADMIN)</div> <div style={S.head}>MEINE MAKERWORLD-ZUGANGSDATEN</div>
{loginStatus && ( {loginStatus && (
<div style={{...S.sub,marginBottom:10}}> <div style={{...S.sub,marginBottom:10}}>
{loginStatus.configured {loginStatus.configured
@@ -335,9 +343,10 @@ function MakerworldTab({ isAdmin, toast }) {
</div> </div>
)} )}
<div style={{...S.sub,marginBottom:8}}> <div style={{...S.sub,marginBottom:8}}>
Wird verschlüsselt gespeichert. Ein echter Browser mit dauerhaftem Profil hält die Session Wird verschlüsselt gespeichert und nur für deinen eigenen Abruf genutzt andere Nutzer sehen
am Laufen bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch, erscheint weder deine Zugangsdaten noch deine Zahlen. Ein echter Browser mit dauerhaftem Profil hält die
hier automatisch die Code-Eingabe. Session am Laufen bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch,
erscheint hier automatisch die Code-Eingabe.
</div> </div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}> <div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}>
<input value={credForm.username} onChange={e=>setCredForm(p=>({...p,username:e.target.value}))} <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')}}> <button onClick={()=>setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'} {showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
</button> </button>
{loginStatus?.configured && (
<button onClick={removeCredentials} style={{...S.btn('#f87171')}}>
🗑 Zugangsdaten entfernen
</button>
)}
</div> </div>
{showCookieFallback && ( {showCookieFallback && (
@@ -379,14 +393,6 @@ function MakerworldTab({ isAdmin, toast }) {
</div> </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 && ( {state?.error && d && (
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}> <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}}> <div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>
@@ -754,7 +760,7 @@ export default function Statistik({ mobile, user, toast }) {
{/* ══ MAKERWORLD ══ */} {/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && ( {tab==='makerworld' && (
<MakerworldTab isAdmin={user?.role==='admin'} toast={toast}/> <MakerworldTab toast={toast}/>
)} )}
</div> </div>
); );