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,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) {
|
||||
|
||||
Reference in New Issue
Block a user