feat(myTischtennis): integrate Playwright for CAPTCHA handling and enhance login form functionality
- Added Playwright as a dependency to handle CAPTCHA challenges during login attempts. - Implemented a new endpoint to retrieve the login form from myTischtennis, parsing necessary fields for user input. - Enhanced the login process to utilize Playwright for browser automation when CAPTCHA is required. - Updated the MyTischtennisDialog component to support local login form submission instead of using an iframe. - Refactored the MyTischtennisController to include proxy functionality for serving resources and handling login submissions. - Improved error handling and user feedback during login attempts, ensuring a smoother user experience.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE_URL = 'https://www.mytischtennis.de';
|
||||
|
||||
@@ -24,36 +25,136 @@ class MyTischtennisClient {
|
||||
async getLoginPage() {
|
||||
try {
|
||||
const response = await this.client.get('/login?next=%2F');
|
||||
const html = response.data;
|
||||
const html = typeof response.data === 'string' ? response.data : String(response.data || '');
|
||||
|
||||
const extractFirst = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match && (match[1] || match[2] || match[3])) {
|
||||
return match[1] || match[2] || match[3];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Parse form action and input fields for frontend login-form endpoint
|
||||
const formMatch = html.match(/<form[^>]*action=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/form>/i);
|
||||
const loginAction = formMatch ? (formMatch[1] || formMatch[2] || '/login') : '/login';
|
||||
const formHtml = formMatch ? formMatch[3] : html;
|
||||
const fields = [];
|
||||
|
||||
const inputRegex = /<input\b([\s\S]*?)>/gi;
|
||||
let inputMatch = null;
|
||||
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
|
||||
const rawAttributes = inputMatch[1] || '';
|
||||
const attributes = {};
|
||||
|
||||
// Parses key="value", key='value', key=value and boolean attributes.
|
||||
const attributeRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
let attributeMatch = null;
|
||||
while ((attributeMatch = attributeRegex.exec(rawAttributes)) !== null) {
|
||||
const key = attributeMatch[1];
|
||||
const value = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? true;
|
||||
attributes[key] = value;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: typeof attributes.name === 'string' ? attributes.name : null,
|
||||
id: typeof attributes.id === 'string' ? attributes.id : null,
|
||||
type: typeof attributes.type === 'string' ? attributes.type : 'text',
|
||||
placeholder: typeof attributes.placeholder === 'string' ? attributes.placeholder : null,
|
||||
autocomplete: typeof attributes.autocomplete === 'string' ? attributes.autocomplete : null,
|
||||
minlength: typeof attributes.minlength === 'string' ? attributes.minlength : null,
|
||||
required: attributes.required === true || attributes.required === 'required',
|
||||
value: typeof attributes.value === 'string' ? attributes.value : null
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: if page is JS-rendered and no input tags are server-rendered, provide usable defaults.
|
||||
const hasEmailField = fields.some((f) => f?.name === 'email' || f?.type === 'email');
|
||||
const hasPasswordField = fields.some((f) => f?.name === 'password' || f?.type === 'password');
|
||||
if (!hasEmailField) {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
id: null,
|
||||
type: 'email',
|
||||
placeholder: null,
|
||||
autocomplete: 'email',
|
||||
minlength: null,
|
||||
required: true,
|
||||
value: null
|
||||
});
|
||||
}
|
||||
if (!hasPasswordField) {
|
||||
fields.push({
|
||||
name: 'password',
|
||||
id: null,
|
||||
type: 'password',
|
||||
placeholder: null,
|
||||
autocomplete: 'current-password',
|
||||
minlength: null,
|
||||
required: true,
|
||||
value: null
|
||||
});
|
||||
}
|
||||
|
||||
// Extract XSRF token from hidden input
|
||||
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
|
||||
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
|
||||
const xsrfToken = extractFirst([
|
||||
/<input[^>]*name=(?:"xsrf"|'xsrf')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"xsrf"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
// Extract CAPTCHA token from hidden input (if present)
|
||||
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
|
||||
const captchaToken = captchaMatch ? captchaMatch[1] : null;
|
||||
const captchaToken = extractFirst([
|
||||
/<input[^>]*name=(?:"captcha"|'captcha')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"captcha"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
// Check if captcha_clicked is true or false
|
||||
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
|
||||
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
|
||||
const captchaClickedRaw = extractFirst([
|
||||
/<input[^>]*name=(?:"captcha_clicked"|'captcha_clicked')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"captcha_clicked"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
const captchaClicked = String(captchaClickedRaw || '').toLowerCase() === 'true';
|
||||
|
||||
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
|
||||
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
|
||||
const requiresCaptcha = html.includes('private-captcha')
|
||||
|| html.includes('name="captcha"')
|
||||
|| html.includes("name='captcha'")
|
||||
|| /captcha/i.test(html);
|
||||
|
||||
// Extract CAPTCHA metadata used by frontend
|
||||
const captchaSiteKey = extractFirst([
|
||||
/data-sitekey=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
|
||||
/(?:^|[,{])\s*"sitekey"\s*:\s*"([^"]+)"/i,
|
||||
/(?:^|[,{])\s*"captchaSiteKey"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
const captchaPuzzleEndpoint = extractFirst([
|
||||
/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
|
||||
/(?:^|[,{])\s*"puzzle_endpoint"\s*:\s*"([^"]+)"/i,
|
||||
/(?:^|[,{])\s*"captchaPuzzleEndpoint"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
console.log('[myTischtennisClient.getLoginPage]', {
|
||||
hasXsrfToken: !!xsrfToken,
|
||||
hasCaptchaToken: !!captchaToken,
|
||||
captchaClicked,
|
||||
requiresCaptcha
|
||||
requiresCaptcha,
|
||||
fieldsCount: fields.length,
|
||||
hasCaptchaSiteKey: !!captchaSiteKey,
|
||||
hasCaptchaPuzzleEndpoint: !!captchaPuzzleEndpoint
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
loginAction,
|
||||
fields,
|
||||
xsrfToken,
|
||||
captchaToken,
|
||||
captchaClicked,
|
||||
requiresCaptcha
|
||||
requiresCaptcha,
|
||||
captchaSiteKey,
|
||||
captchaPuzzleEndpoint
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching login page:', error.message);
|
||||
@@ -247,6 +348,202 @@ class MyTischtennisClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-based fallback login for CAPTCHA flows.
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
* @returns {Promise<Object>} Login response with token and session data
|
||||
*/
|
||||
async loginWithBrowserAutomation(email, password) {
|
||||
let browser = null;
|
||||
let context = null;
|
||||
try {
|
||||
console.log('[myTischtennisClient.playwright] Start browser login flow');
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
console.log('[myTischtennisClient.playwright] Page loaded');
|
||||
|
||||
// Best-effort: Consent/overlay dialogs that can block form interaction.
|
||||
const consentSelectors = [
|
||||
'#onetrust-accept-btn-handler',
|
||||
'button:has-text("Alle akzeptieren")',
|
||||
'button:has-text("Akzeptieren")',
|
||||
'button:has-text("Einverstanden")'
|
||||
];
|
||||
for (const selector of consentSelectors) {
|
||||
try {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.count()) {
|
||||
await button.click({ timeout: 1500 });
|
||||
console.log('[myTischtennisClient.playwright] Consent dialog accepted');
|
||||
break;
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore and try next selector
|
||||
}
|
||||
}
|
||||
|
||||
// Fill credentials
|
||||
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
|
||||
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
|
||||
console.log('[myTischtennisClient.playwright] Credentials filled');
|
||||
|
||||
// Try to interact with private-captcha if present.
|
||||
const captchaHost = page.locator('private-captcha').first();
|
||||
if (await captchaHost.count()) {
|
||||
try {
|
||||
await page.waitForTimeout(1200);
|
||||
const interaction = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
if (!checkbox) {
|
||||
return { clicked: false, reason: 'checkbox-missing' };
|
||||
}
|
||||
checkbox.click();
|
||||
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return {
|
||||
clicked: true,
|
||||
viaShadowRoot: true,
|
||||
className: checkbox.className || null,
|
||||
checked: !!checkbox.checked
|
||||
};
|
||||
});
|
||||
console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction);
|
||||
|
||||
// Wait until hidden captcha fields are populated by site scripts.
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
}, { timeout: 15000 });
|
||||
const captchaState = await page.evaluate(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
return {
|
||||
captchaLen: captchaField?.value?.length || 0,
|
||||
captchaClicked: clickedField?.value || null
|
||||
};
|
||||
});
|
||||
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
|
||||
} catch (_waitErr) {
|
||||
// Keep going; some flows still succeed without explicit hidden field update.
|
||||
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
|
||||
}
|
||||
} catch (captchaError) {
|
||||
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure captcha_clicked field is set if available.
|
||||
await page.evaluate(() => {
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
if (clickedField && !clickedField.value) {
|
||||
clickedField.value = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
if (await submitButton.count()) {
|
||||
await submitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
} else {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
console.log('[myTischtennisClient.playwright] Submit clicked');
|
||||
|
||||
// Wait for auth cookie after submit (polling avoids timing races).
|
||||
let authCookieObj = null;
|
||||
const maxAttempts = 20;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const cookies = await context.cookies();
|
||||
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token');
|
||||
if (authCookieObj?.value) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
if (!authCookieObj || !authCookieObj.value) {
|
||||
let errorText = null;
|
||||
try {
|
||||
const textContent = await page.locator('body').innerText({ timeout: 1000 });
|
||||
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
|
||||
errorText = 'Captcha-Bestätigung fehlgeschlagen';
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore text read errors
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorText
|
||||
? `Playwright-Login fehlgeschlagen: ${errorText}`
|
||||
: 'Playwright-Login fehlgeschlagen: Kein sb-10-auth-token Cookie gefunden'
|
||||
};
|
||||
}
|
||||
|
||||
// Cookie value is expected as "base64-<tokenData>"
|
||||
const tokenMatch = String(authCookieObj.value).match(/^base64-(.+)$/);
|
||||
if (!tokenMatch) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Playwright-Login fehlgeschlagen: Token-Format ungültig'
|
||||
};
|
||||
}
|
||||
|
||||
let tokenData;
|
||||
try {
|
||||
tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
|
||||
} catch (decodeError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Playwright-Login fehlgeschlagen: Token konnte nicht dekodiert werden (${decodeError.message})`
|
||||
};
|
||||
}
|
||||
|
||||
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
|
||||
console.log('[myTischtennisClient.playwright] Browser login successful');
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: tokenData.expires_at,
|
||||
expiresIn: tokenData.expires_in,
|
||||
user: tokenData.user,
|
||||
cookie
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[myTischtennisClient.playwright] Browser login failed:', error?.message || error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || 'Playwright-Login fehlgeschlagen'
|
||||
};
|
||||
} finally {
|
||||
if (context) {
|
||||
try {
|
||||
await context.close();
|
||||
} catch (contextCloseError) {
|
||||
console.warn('[myTischtennisClient.playwright] Context close warning:', contextCloseError?.message || contextCloseError);
|
||||
}
|
||||
}
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (browserCloseError) {
|
||||
console.warn('[myTischtennisClient.playwright] Browser close warning:', browserCloseError?.message || browserCloseError);
|
||||
}
|
||||
console.log('[myTischtennisClient.playwright] Browser closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify login credentials
|
||||
* @param {string} email - myTischtennis email
|
||||
@@ -411,4 +708,3 @@ class MyTischtennisClient {
|
||||
}
|
||||
|
||||
export default new MyTischtennisClient();
|
||||
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import axios from 'axios';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
|
||||
const MYTT_ORIGIN = 'https://www.mytischtennis.de';
|
||||
const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy';
|
||||
|
||||
function rewriteMytischtennisContent(content) {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
|
||||
// Root-relative Build/Fonts über unseren Same-Origin-Proxy laden.
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/build\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/fonts\//g,
|
||||
`$1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// Absolute Build/Fonts-URLs ebenfalls auf den Proxy biegen.
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/build\//g,
|
||||
`${MYTT_PROXY_PREFIX}/build/`
|
||||
);
|
||||
rewritten = rewritten.replace(
|
||||
/https:\/\/www\.mytischtennis\.de\/fonts\//g,
|
||||
`${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// CSS url(/fonts/...) Fälle.
|
||||
rewritten = rewritten.replace(
|
||||
/url\((["']?)\/fonts\//g,
|
||||
`url($1${MYTT_PROXY_PREFIX}/fonts/`
|
||||
);
|
||||
|
||||
// Captcha-Endpunkt muss ebenfalls same-origin über Proxy erreichbar sein.
|
||||
rewritten = rewritten.replace(
|
||||
/(["'])\/api\/private-captcha/g,
|
||||
`$1${MYTT_PROXY_PREFIX}/api/private-captcha`
|
||||
);
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
class MyTischtennisController {
|
||||
/**
|
||||
@@ -36,6 +82,49 @@ class MyTischtennisController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/login-form
|
||||
* Parsed login form data from mytischtennis.de
|
||||
*/
|
||||
async getLoginForm(req, res, next) {
|
||||
try {
|
||||
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
|
||||
const result = await myTischtennisClient.getLoginPage();
|
||||
|
||||
if (!result.success) {
|
||||
throw new HttpError('Login-Formular konnte nicht geladen werden', 502);
|
||||
}
|
||||
|
||||
const publicFields = (result.fields || [])
|
||||
.filter((field) => ['email', 'password'].includes(field.type) || field.name === 'email' || field.name === 'password')
|
||||
.map((field) => ({
|
||||
name: field.name,
|
||||
id: field.id,
|
||||
type: field.type,
|
||||
placeholder: field.placeholder || null,
|
||||
required: !!field.required,
|
||||
autocomplete: field.autocomplete || null,
|
||||
minlength: field.minlength ? Number(field.minlength) : null
|
||||
}));
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
form: {
|
||||
action: result.loginAction,
|
||||
fields: publicFields
|
||||
},
|
||||
captcha: {
|
||||
required: !!result.requiresCaptcha,
|
||||
siteKey: result.captchaSiteKey || null,
|
||||
puzzleEndpoint: result.captchaPuzzleEndpoint || null,
|
||||
solutionField: result.captchaSolutionField || 'captcha'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/account
|
||||
* Create or update myTischtennis account
|
||||
@@ -43,7 +132,9 @@ class MyTischtennisController {
|
||||
async upsertAccount(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
|
||||
const { email, password, savePassword, userPassword } = req.body;
|
||||
const hasAutoUpdateRatings = Object.prototype.hasOwnProperty.call(req.body, 'autoUpdateRatings');
|
||||
const autoUpdateRatings = hasAutoUpdateRatings ? req.body.autoUpdateRatings : undefined;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('E-Mail-Adresse erforderlich', 400);
|
||||
@@ -59,7 +150,7 @@ class MyTischtennisController {
|
||||
email,
|
||||
password,
|
||||
savePassword || false,
|
||||
autoUpdateRatings || false,
|
||||
autoUpdateRatings,
|
||||
userPassword
|
||||
);
|
||||
|
||||
@@ -226,7 +317,7 @@ class MyTischtennisController {
|
||||
req.userId = userId;
|
||||
|
||||
// Lade die Login-Seite von mytischtennis.de
|
||||
const response = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
|
||||
const response = await axios.get(`${MYTT_ORIGIN}/login?next=%2F`, {
|
||||
headers: {
|
||||
'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',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
@@ -262,6 +353,14 @@ class MyTischtennisController {
|
||||
/action="\/login/g,
|
||||
'action="/api/mytischtennis/login-submit'
|
||||
);
|
||||
html = rewriteMytischtennisContent(html);
|
||||
|
||||
// MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit
|
||||
// Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login
|
||||
// reicht die serverseitig gerenderte Form aus; deshalb Bootstrap-Skripte entfernen.
|
||||
html = html.replace(/<script\b[^>]*type=(?:"|')module(?:"|')[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/<script\b[^>]*src=(?:"|')[^"']*\/build\/[^"']*(?:"|')[^>]*>\s*<\/script>/gi, '');
|
||||
html = html.replace(/<link\b[^>]*rel=(?:"|')modulepreload(?:"|')[^>]*>/gi, '');
|
||||
}
|
||||
|
||||
// Setze Content-Type
|
||||
@@ -275,6 +374,55 @@ class MyTischtennisController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/proxy/*
|
||||
* Same-Origin-Proxy für mytischtennis Build-/Font-/Captcha-Ressourcen
|
||||
*/
|
||||
async proxyRemote(req, res, next) {
|
||||
try {
|
||||
const proxyPath = req.params[0] || '';
|
||||
const queryString = new URLSearchParams(req.query || {}).toString();
|
||||
const targetUrl = `${MYTT_ORIGIN}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const upstream = await axios.get(targetUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
|
||||
'Accept': req.headers.accept || '*/*',
|
||||
'Accept-Language': req.headers['accept-language'] || 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
...(req.headers.cookie ? { 'Cookie': req.headers.cookie } : {})
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
// Wichtige Header durchreichen
|
||||
const passthroughHeaders = ['content-type', 'cache-control', 'etag', 'last-modified', 'expires'];
|
||||
for (const headerName of passthroughHeaders) {
|
||||
const value = upstream.headers[headerName];
|
||||
if (value) {
|
||||
res.setHeader(headerName, value);
|
||||
}
|
||||
}
|
||||
if (upstream.headers['set-cookie']) {
|
||||
res.setHeader('Set-Cookie', upstream.headers['set-cookie']);
|
||||
}
|
||||
|
||||
const contentType = String(upstream.headers['content-type'] || '').toLowerCase();
|
||||
const isTextLike = /(text\/|javascript|json|xml|svg)/.test(contentType);
|
||||
|
||||
if (isTextLike) {
|
||||
const asText = Buffer.from(upstream.data).toString('utf-8');
|
||||
const rewritten = rewriteMytischtennisContent(asText);
|
||||
return res.status(upstream.status).send(rewritten);
|
||||
}
|
||||
|
||||
return res.status(upstream.status).send(upstream.data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Proxy von mytischtennis-Ressourcen:', error.message);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/login-submit
|
||||
* Proxy für Login-Form-Submission
|
||||
@@ -300,14 +448,81 @@ class MyTischtennisController {
|
||||
if (req.body.__token) {
|
||||
delete req.body.__token;
|
||||
}
|
||||
|
||||
// Hole Cookies aus dem Request
|
||||
|
||||
// Hole Cookies aus dem Request (wird auch für CAPTCHA-Fallback benötigt)
|
||||
const cookies = req.headers.cookie || '';
|
||||
|
||||
// Normalisiere Payload
|
||||
const payload = { ...(req.body || {}) };
|
||||
const mask = (v) => (typeof v === 'string' && v.length > 12 ? `${v.slice(0, 12)}...(${v.length})` : v);
|
||||
|
||||
// Falls captcha im Browser-Kontext nicht gesetzt wurde, versuche serverseitigen Fallback
|
||||
if (!payload.captcha) {
|
||||
try {
|
||||
const loginPageResponse = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Referer': 'https://www.mytischtennis.de/'
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
const html = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
|
||||
const siteKeyMatch = html.match(/data-sitekey=(?:"([^"]+)"|'([^']+)')/i);
|
||||
const puzzleEndpointMatch = html.match(/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)')/i);
|
||||
const siteKey = siteKeyMatch ? (siteKeyMatch[1] || siteKeyMatch[2]) : null;
|
||||
const puzzleEndpoint = puzzleEndpointMatch ? (puzzleEndpointMatch[1] || puzzleEndpointMatch[2]) : null;
|
||||
|
||||
if (siteKey && puzzleEndpoint) {
|
||||
const puzzleResponse = await axios.get(`${puzzleEndpoint}?sitekey=${encodeURIComponent(siteKey)}`, {
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'Accept': '*/*',
|
||||
'Origin': 'https://www.mytischtennis.de',
|
||||
'Referer': 'https://www.mytischtennis.de/'
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
if (puzzleResponse.status === 200 && typeof puzzleResponse.data === 'string' && puzzleResponse.data.trim()) {
|
||||
payload.captcha = puzzleResponse.data.trim();
|
||||
payload.captcha_clicked = 'true';
|
||||
}
|
||||
}
|
||||
} catch (captchaFallbackError) {
|
||||
console.warn('[submitLogin] CAPTCHA-Fallback fehlgeschlagen:', captchaFallbackError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn captcha vorhanden ist, als bestätigt markieren
|
||||
if (payload.captcha && !payload.captcha_clicked) {
|
||||
payload.captcha_clicked = 'true';
|
||||
}
|
||||
|
||||
console.log('[submitLogin] Incoming payload fields:', {
|
||||
keys: Object.keys(payload),
|
||||
hasEmail: !!payload.email,
|
||||
hasPassword: !!payload.password,
|
||||
xsrf: mask(payload.xsrf),
|
||||
captchaClicked: payload.captcha_clicked,
|
||||
captcha: mask(payload.captcha)
|
||||
});
|
||||
|
||||
// Form-Daten sauber als x-www-form-urlencoded serialisieren
|
||||
const formData = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Leite den Login-Request an mytischtennis.de weiter
|
||||
const response = await axios.post(
|
||||
'https://www.mytischtennis.de/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
|
||||
req.body, // Form-Daten
|
||||
formData.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
@@ -321,6 +536,34 @@ class MyTischtennisController {
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[submitLogin] Upstream response:', {
|
||||
status: response.status,
|
||||
hasSetCookie: Array.isArray(response.headers['set-cookie']) && response.headers['set-cookie'].length > 0,
|
||||
bodyPreview: typeof response.data === 'string'
|
||||
? response.data.slice(0, 220)
|
||||
: JSON.stringify(response.data || {}).slice(0, 220)
|
||||
});
|
||||
|
||||
// Falls CAPTCHA-Bestätigung im Proxy-Flow fehlschlägt:
|
||||
// Fallback auf echten Browser-Login (Playwright), dann Session direkt speichern.
|
||||
const upstreamBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data || {});
|
||||
const isCaptchaFailure = response.status === 400
|
||||
&& (upstreamBody.includes('Captcha-Bestätigung fehlgeschlagen') || upstreamBody.includes('Captcha-Bestätigung ist erforderlich'));
|
||||
|
||||
if (isCaptchaFailure && userId && payload.email && payload.password) {
|
||||
console.log('[submitLogin] CAPTCHA-Fehler erkannt, starte Playwright-Fallback...');
|
||||
const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password);
|
||||
|
||||
if (browserLogin.success && browserLogin.cookie) {
|
||||
await this.saveSessionFromCookie(userId, browserLogin.cookie);
|
||||
return res.status(200).send(
|
||||
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
|
||||
);
|
||||
}
|
||||
|
||||
console.warn('[submitLogin] Playwright-Fallback fehlgeschlagen:', browserLogin.error);
|
||||
}
|
||||
|
||||
// Setze Cookies aus der Response
|
||||
const setCookieHeaders = response.headers['set-cookie'];
|
||||
if (setCookieHeaders) {
|
||||
|
||||
45
backend/package-lock.json
generated
45
backend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"nodemailer": "^7.0.9",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"playwright": "^1.58.2",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.8.1"
|
||||
@@ -3275,6 +3276,50 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"nodemailer": "^7.0.9",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"playwright": "^1.58.2",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.8.1"
|
||||
|
||||
@@ -10,6 +10,9 @@ const router = express.Router();
|
||||
// GET /api/mytischtennis/login-page - Proxy für Login-Seite (für iframe)
|
||||
router.get('/login-page', myTischtennisController.getLoginPage);
|
||||
|
||||
// GET /api/mytischtennis/proxy/* - Same-Origin-Proxy für mytischtennis Assets/APIs
|
||||
router.get('/proxy/*', myTischtennisController.proxyRemote);
|
||||
|
||||
// POST /api/mytischtennis/login-submit - Proxy für Login-Form-Submission
|
||||
router.post('/login-submit', myTischtennisController.submitLogin);
|
||||
|
||||
@@ -25,6 +28,9 @@ router.get('/account', myTischtennisController.getAccount);
|
||||
// GET /api/mytischtennis/status - Check status (alle dürfen lesen)
|
||||
router.get('/status', myTischtennisController.getStatus);
|
||||
|
||||
// GET /api/mytischtennis/login-form - Parse mytischtennis Login-Form
|
||||
router.get('/login-form', myTischtennisController.getLoginForm);
|
||||
|
||||
// POST /api/mytischtennis/account - Create or update account (alle dürfen bearbeiten)
|
||||
router.post('/account', myTischtennisController.upsertAccount);
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ app.use(cors({
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request Logging Middleware - loggt alle API-Requests
|
||||
// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne
|
||||
|
||||
@@ -58,6 +58,15 @@ class MyTischtennisService {
|
||||
|
||||
// Login-Versuch bei myTischtennis
|
||||
loginResult = await myTischtennisClient.login(email, password);
|
||||
if (!loginResult.success && loginResult.requiresCaptcha) {
|
||||
console.log('[myTischtennisService.upsertAccount] CAPTCHA-Fehler, versuche Playwright-Fallback...');
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(email, password);
|
||||
if (playwrightResult.success) {
|
||||
loginResult = playwrightResult;
|
||||
} else {
|
||||
console.warn('[myTischtennisService.upsertAccount] Playwright-Fallback fehlgeschlagen:', playwrightResult.error);
|
||||
}
|
||||
}
|
||||
if (!loginResult.success) {
|
||||
const statusCode = loginResult.requiresCaptcha ? 400 : 401;
|
||||
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.';
|
||||
@@ -74,10 +83,14 @@ class MyTischtennisService {
|
||||
const now = new Date();
|
||||
|
||||
if (account) {
|
||||
const effectiveAutoUpdateRatings = autoUpdateRatings === undefined
|
||||
? account.autoUpdateRatings
|
||||
: Boolean(autoUpdateRatings);
|
||||
|
||||
// Update existing
|
||||
account.email = email;
|
||||
account.savePassword = savePassword;
|
||||
account.autoUpdateRatings = autoUpdateRatings;
|
||||
account.autoUpdateRatings = savePassword ? effectiveAutoUpdateRatings : false;
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
@@ -115,7 +128,7 @@ class MyTischtennisService {
|
||||
userId,
|
||||
email,
|
||||
savePassword,
|
||||
autoUpdateRatings,
|
||||
autoUpdateRatings: savePassword ? Boolean(autoUpdateRatings) : false,
|
||||
lastLoginAttempt: password ? now : null,
|
||||
lastLoginSuccess: loginResult?.success ? now : null
|
||||
};
|
||||
@@ -225,18 +238,48 @@ class MyTischtennisService {
|
||||
// Login-Versuch mit Passwort
|
||||
console.log('[myTischtennisService.verifyLogin] Attempting login for user:', account.email);
|
||||
const loginResult = await myTischtennisClient.login(account.email, password);
|
||||
console.log('[myTischtennisService.verifyLogin] Login result:', { success: loginResult.success, error: loginResult.error, requiresCaptcha: loginResult.requiresCaptcha });
|
||||
let effectiveLoginResult = loginResult;
|
||||
if (!effectiveLoginResult.success && effectiveLoginResult.requiresCaptcha) {
|
||||
console.log('[myTischtennisService.verifyLogin] CAPTCHA-Fehler, versuche Playwright-Fallback...');
|
||||
try {
|
||||
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(account.email, password);
|
||||
if (playwrightResult.success) {
|
||||
effectiveLoginResult = playwrightResult;
|
||||
} else {
|
||||
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback fehlgeschlagen:', playwrightResult.error);
|
||||
effectiveLoginResult = {
|
||||
success: false,
|
||||
error: playwrightResult.error || 'Playwright-Fallback fehlgeschlagen',
|
||||
requiresCaptcha: true,
|
||||
status: 400
|
||||
};
|
||||
}
|
||||
} catch (playwrightError) {
|
||||
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback Exception:', playwrightError?.message || playwrightError);
|
||||
effectiveLoginResult = {
|
||||
success: false,
|
||||
error: `Playwright-Fallback Exception: ${playwrightError?.message || 'Unbekannter Fehler'}`,
|
||||
requiresCaptcha: true,
|
||||
status: 400
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log('[myTischtennisService.verifyLogin] Login result:', {
|
||||
success: effectiveLoginResult.success,
|
||||
error: effectiveLoginResult.error,
|
||||
requiresCaptcha: effectiveLoginResult.requiresCaptcha
|
||||
});
|
||||
|
||||
if (loginResult.success) {
|
||||
if (effectiveLoginResult.success) {
|
||||
account.lastLoginSuccess = now;
|
||||
account.accessToken = loginResult.accessToken;
|
||||
account.refreshToken = loginResult.refreshToken;
|
||||
account.expiresAt = loginResult.expiresAt;
|
||||
account.cookie = loginResult.cookie;
|
||||
account.userData = loginResult.user;
|
||||
account.accessToken = effectiveLoginResult.accessToken;
|
||||
account.refreshToken = effectiveLoginResult.refreshToken;
|
||||
account.expiresAt = effectiveLoginResult.expiresAt;
|
||||
account.cookie = effectiveLoginResult.cookie;
|
||||
account.userData = effectiveLoginResult.user;
|
||||
|
||||
// Hole Club-ID und Federation
|
||||
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
|
||||
const profileResult = await myTischtennisClient.getUserProfile(effectiveLoginResult.cookie);
|
||||
|
||||
if (profileResult.success) {
|
||||
account.clubId = profileResult.clubId || account.clubId;
|
||||
@@ -250,25 +293,31 @@ class MyTischtennisService {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: loginResult.accessToken,
|
||||
refreshToken: loginResult.refreshToken,
|
||||
expiresAt: loginResult.expiresAt,
|
||||
user: loginResult.user,
|
||||
accessToken: effectiveLoginResult.accessToken,
|
||||
refreshToken: effectiveLoginResult.refreshToken,
|
||||
expiresAt: effectiveLoginResult.expiresAt,
|
||||
user: effectiveLoginResult.user,
|
||||
clubId: account.clubId,
|
||||
clubName: account.clubName
|
||||
};
|
||||
} else {
|
||||
// Prevent stale "success" from previously valid sessions after an explicit failed login attempt.
|
||||
account.accessToken = null;
|
||||
account.refreshToken = null;
|
||||
account.cookie = null;
|
||||
account.expiresAt = null;
|
||||
account.userData = null;
|
||||
await account.save(); // Save lastLoginAttempt
|
||||
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen';
|
||||
const errorMessage = effectiveLoginResult.error || 'myTischtennis-Login fehlgeschlagen';
|
||||
// Verwende den Status-Code vom myTischtennisClient, falls vorhanden, sonst 401
|
||||
// Wenn CAPTCHA erforderlich ist, verwende 400 statt 401
|
||||
const statusCode = loginResult.requiresCaptcha
|
||||
const statusCode = effectiveLoginResult.requiresCaptcha
|
||||
? 400
|
||||
: (loginResult.status && loginResult.status >= 400 && loginResult.status < 600
|
||||
? loginResult.status
|
||||
: (effectiveLoginResult.status && effectiveLoginResult.status >= 400 && effectiveLoginResult.status < 600
|
||||
? effectiveLoginResult.status
|
||||
: 401);
|
||||
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`, loginResult.requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
|
||||
if (loginResult.requiresCaptcha) {
|
||||
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`, effectiveLoginResult.requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
|
||||
if (effectiveLoginResult.requiresCaptcha) {
|
||||
throw new HttpError({ code: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED', params: { message: errorMessage } }, statusCode);
|
||||
}
|
||||
throw new HttpError(errorMessage, statusCode);
|
||||
|
||||
@@ -115,7 +115,6 @@ class SchedulerService {
|
||||
|
||||
/**
|
||||
* Manually trigger rating updates (for testing)
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerRatingUpdates() {
|
||||
devLog('[Scheduler] Manual rating updates trigger called');
|
||||
@@ -124,7 +123,6 @@ class SchedulerService {
|
||||
|
||||
/**
|
||||
* Manually trigger match results fetch (for testing)
|
||||
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
|
||||
*/
|
||||
async triggerMatchResultsFetch() {
|
||||
devLog('[Scheduler] Manual match results fetch trigger called');
|
||||
|
||||
Reference in New Issue
Block a user