feat: MakerWorld-Anbindung pro Nutzer (eigener Login, eigenes Profil, eigene Daten)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user