Implement login page proxy and CAPTCHA handling in MyTischtennisClient and Controller. Enhance login process with CAPTCHA token extraction and error handling. Update frontend to support iframe-based login and improve user experience with loading indicators.

This commit is contained in:
Torsten Schulz (local)
2025-11-23 15:18:53 +01:00
parent b74cb30cf6
commit f7a799ea7f
15 changed files with 702 additions and 284 deletions

View File

@@ -17,19 +17,146 @@ class MyTischtennisClient {
});
}
/**
* Get login page to extract XSRF token and CAPTCHA token
* @returns {Promise<Object>} Object with xsrfToken, captchaToken, and captchaClicked flag
*/
async getLoginPage() {
try {
const response = await this.client.get('/login?next=%2F');
const html = response.data;
// Extract XSRF token from hidden input
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
// Extract CAPTCHA token from hidden input (if present)
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
const captchaToken = captchaMatch ? captchaMatch[1] : null;
// Check if captcha_clicked is true or false
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
console.log('[myTischtennisClient.getLoginPage]', {
hasXsrfToken: !!xsrfToken,
hasCaptchaToken: !!captchaToken,
captchaClicked,
requiresCaptcha
});
return {
success: true,
xsrfToken,
captchaToken,
captchaClicked,
requiresCaptcha
};
} catch (error) {
console.error('Error fetching login page:', error.message);
return {
success: false,
error: error.message
};
}
}
/**
* Login to myTischtennis API
* @param {string} email - myTischtennis email (not username!)
* @param {string} password - myTischtennis password
* @param {string} captchaToken - Optional CAPTCHA token if required
* @param {string} xsrfToken - Optional XSRF token (will be fetched if not provided)
* @returns {Promise<Object>} Login response with token and session data
*/
async login(email, password) {
async login(email, password, captchaToken = null, xsrfToken = null) {
try {
let loginPage = null;
let captchaClicked = false;
// If XSRF token not provided, fetch login page to get it
if (!xsrfToken) {
loginPage = await this.getLoginPage();
if (!loginPage.success) {
return {
success: false,
error: 'Konnte Login-Seite nicht abrufen: ' + loginPage.error
};
}
xsrfToken = loginPage.xsrfToken;
// If CAPTCHA token not provided but found in HTML, use it
if (!captchaToken && loginPage.captchaToken) {
captchaToken = loginPage.captchaToken;
captchaClicked = loginPage.captchaClicked;
console.log('[myTischtennisClient.login] CAPTCHA-Token aus HTML extrahiert, captcha_clicked:', captchaClicked);
}
// If CAPTCHA is required but no token found yet, wait and try to get it again
// Das CAPTCHA-System löst das Puzzle im Hintergrund via JavaScript, daher kann es einen Moment dauern
// Wir müssen mehrmals versuchen, da das Token erst generiert wird, nachdem das JavaScript gelaufen ist
if (loginPage.requiresCaptcha && !captchaToken) {
console.log('[myTischtennisClient.login] CAPTCHA erforderlich, aber noch kein Token gefunden. Warte und versuche erneut...');
// Versuche bis zu 5 Mal, das CAPTCHA-Token zu erhalten
let maxRetries = 5;
let retryCount = 0;
let foundToken = false;
while (retryCount < maxRetries && !foundToken) {
// Warte 2-4 Sekunden zwischen den Versuchen
const waitMs = Math.floor(Math.random() * 2000) + 2000; // 2000-4000ms
console.log(`[myTischtennisClient.login] Versuch ${retryCount + 1}/${maxRetries}: Warte ${waitMs}ms...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
// Versuche erneut, die Login-Seite abzurufen, um das gelöste CAPTCHA-Token zu erhalten
const retryLoginPage = await this.getLoginPage();
if (retryLoginPage.success && retryLoginPage.captchaToken) {
captchaToken = retryLoginPage.captchaToken;
captchaClicked = retryLoginPage.captchaClicked;
xsrfToken = retryLoginPage.xsrfToken || xsrfToken; // Aktualisiere XSRF-Token falls nötig
foundToken = true;
console.log(`[myTischtennisClient.login] CAPTCHA-Token nach ${retryCount + 1} Versuchen gefunden, captcha_clicked:`, captchaClicked);
} else {
retryCount++;
}
}
if (!foundToken) {
// Wenn nach allen Versuchen kein Token gefunden wurde, Fehler zurückgeben
console.log('[myTischtennisClient.login] CAPTCHA-Token konnte nach mehreren Versuchen nicht gefunden werden');
return {
success: false,
error: 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.',
requiresCaptcha: true
};
}
}
// Zufällige Verzögerung von 2-5 Sekunden zwischen Laden des Forms und Absenden
// Simuliert menschliches Verhalten und gibt dem CAPTCHA-System Zeit
const delayMs = Math.floor(Math.random() * 3000) + 2000; // 2000-5000ms
console.log(`[myTischtennisClient] Warte ${delayMs}ms vor Login-Request (simuliert menschliches Verhalten)`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
// Create form data
const formData = new URLSearchParams();
formData.append('email', email);
formData.append('password', password);
formData.append('intent', 'login');
if (xsrfToken) {
formData.append('xsrf', xsrfToken);
}
if (captchaToken) {
formData.append('captcha', captchaToken);
formData.append('captcha_clicked', captchaClicked ? 'true' : 'false');
}
const response = await this.client.post(
'/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
@@ -87,11 +214,35 @@ class MyTischtennisClient {
};
} catch (error) {
const statusCode = error.response?.status || 500;
console.error('MyTischtennis login error:', error.message, `(Status: ${statusCode})`);
const responseData = error.response?.data;
// Check if response contains CAPTCHA error
let errorMessage = error.response?.data?.message || error.message || 'Login fehlgeschlagen';
let requiresCaptcha = false;
// Check for CAPTCHA-related errors in response
if (typeof responseData === 'string') {
if (responseData.includes('Captcha') || responseData.includes('CAPTCHA') ||
responseData.includes('captcha') || responseData.includes('Captcha-Bestätigung')) {
requiresCaptcha = true;
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
}
} else if (responseData && typeof responseData === 'object') {
// Check for CAPTCHA errors in JSON response or HTML
const dataString = JSON.stringify(responseData);
if (dataString.includes('Captcha') || dataString.includes('CAPTCHA') ||
dataString.includes('captcha') || dataString.includes('Captcha-Bestätigung')) {
requiresCaptcha = true;
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
}
}
console.error('MyTischtennis login error:', errorMessage, `(Status: ${statusCode})`, requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
return {
success: false,
error: error.response?.data?.message || error.message || 'Login fehlgeschlagen',
status: statusCode
error: errorMessage,
status: statusCode,
requiresCaptcha
};
}
}

View File

@@ -34,6 +34,7 @@ export const ERROR_CODES = {
MYTISCHTENNIS_PASSWORD_NOT_SAVED: 'ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED',
MYTISCHTENNIS_SESSION_EXPIRED: 'ERROR_MYTISCHTENNIS_SESSION_EXPIRED',
MYTISCHTENNIS_NO_PASSWORD_SAVED: 'ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED',
MYTISCHTENNIS_CAPTCHA_REQUIRED: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED',
// Mitglieder
MEMBER_NOT_FOUND: 'ERROR_MEMBER_NOT_FOUND',

View File

@@ -1,5 +1,6 @@
import myTischtennisService from '../services/myTischtennisService.js';
import HttpError from '../exceptions/HttpError.js';
import axios from 'axios';
class MyTischtennisController {
/**
@@ -199,6 +200,292 @@ class MyTischtennisController {
next(error);
}
}
/**
* GET /api/mytischtennis/login-page
* Proxy für Login-Seite (für iframe)
* Lädt die Login-Seite von mytischtennis.de und modifiziert sie, sodass Form-Submissions über unseren Proxy gehen
* Authentifizierung ist optional - Token kann als Query-Parameter übergeben werden
*/
async getLoginPage(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (optional)
let userId = null;
const token = req.query.token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
if (token) {
try {
const jwt = (await import('jsonwebtoken')).default;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
userId = decoded.userId;
} catch (err) {
// Token ungültig - ignorieren
}
}
// Speichere userId im Request für submitLogin
req.userId = userId;
// Lade die Login-Seite von mytischtennis.de
const response = await axios.get('https://www.mytischtennis.de/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',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
},
maxRedirects: 5,
validateStatus: () => true // Akzeptiere alle Status-Codes
});
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {
res.setHeader('Set-Cookie', setCookieHeaders);
}
// Modifiziere HTML: Ändere Form-Action auf unseren Proxy
let html = response.data;
if (typeof html === 'string') {
// Füge Token als Hidden-Input hinzu, damit submitLogin die userId bekommt
const tokenInput = userId ? `<input type="hidden" name="__token" value="${token}" />` : '';
// Ersetze Form-Action URLs und füge Token-Input hinzu
html = html.replace(
/(<form[^>]*action="[^"]*\/login[^"]*"[^>]*>)/g,
`$1${tokenInput}`
);
html = html.replace(
/action="([^"]*\/login[^"]*)"/g,
'action="/api/mytischtennis/login-submit"'
);
// Ersetze auch relative URLs
html = html.replace(
/action="\/login/g,
'action="/api/mytischtennis/login-submit'
);
}
// Setze Content-Type
res.setHeader('Content-Type', response.headers['content-type'] || 'text/html; charset=utf-8');
// Sende den modifizierten HTML-Inhalt
res.status(response.status).send(html);
} catch (error) {
console.error('Fehler beim Laden der Login-Seite:', error);
next(error);
}
}
/**
* POST /api/mytischtennis/login-submit
* Proxy für Login-Form-Submission
* Leitet den Login-Request durch, damit Cookies im Backend-Kontext bleiben
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
*/
async submitLogin(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (aus Query-Parameter oder Hidden-Input)
let userId = null;
const token = req.query.token || req.body.__token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
if (token) {
try {
const jwt = (await import('jsonwebtoken')).default;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
userId = decoded.userId;
} catch (err) {
// Token ungültig - ignorieren
}
}
// Entferne __token aus req.body, damit es nicht an mytischtennis.de gesendet wird
if (req.body.__token) {
delete req.body.__token;
}
// Hole Cookies aus dem Request
const cookies = req.headers.cookie || '';
// 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
{
headers: {
'Cookie': cookies,
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': 'https://www.mytischtennis.de/login?next=%2F'
},
maxRedirects: 0,
validateStatus: () => true
}
);
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {
res.setHeader('Set-Cookie', setCookieHeaders);
}
// Setze andere relevante Headers
if (response.headers['content-type']) {
res.setHeader('Content-Type', response.headers['content-type']);
}
if (response.headers['location']) {
res.setHeader('Location', response.headers['location']);
}
// Prüfe, ob Login erfolgreich war (durch Prüfung der Cookies)
const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token='));
if (authCookie && userId) {
// Login erfolgreich - speichere Session (nur wenn userId vorhanden)
await this.saveSessionFromCookie(userId, authCookie);
}
// Sende Response weiter
res.status(response.status).send(response.data);
} catch (error) {
console.error('Fehler beim Login-Submit:', error);
next(error);
}
}
/**
* Speichere Session-Daten aus Cookie
*/
async saveSessionFromCookie(userId, cookieString) {
try {
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
if (!tokenMatch) {
throw new Error('Token-Format ungültig');
}
const base64Token = tokenMatch[1];
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
const tokenData = JSON.parse(decodedToken);
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
if (myTischtennisAccount) {
myTischtennisAccount.accessToken = tokenData.access_token;
myTischtennisAccount.refreshToken = tokenData.refresh_token;
myTischtennisAccount.expiresAt = tokenData.expires_at;
myTischtennisAccount.cookie = cookieString.split(';')[0].trim();
myTischtennisAccount.userData = tokenData.user;
myTischtennisAccount.lastLoginSuccess = new Date();
myTischtennisAccount.lastLoginAttempt = new Date();
// Hole Club-Informationen
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie);
if (profileResult.success) {
myTischtennisAccount.clubId = profileResult.clubId;
myTischtennisAccount.clubName = profileResult.clubName;
myTischtennisAccount.fedNickname = profileResult.fedNickname;
}
await myTischtennisAccount.save();
}
} catch (error) {
console.error('Fehler beim Speichern der Session:', error);
throw error;
}
}
/**
* POST /api/mytischtennis/extract-session
* Extrahiere Session nach Login im iframe
* Versucht, die Session-Daten aus den Cookies zu extrahieren
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
*/
async extractSession(req, res, next) {
try {
// Versuche, userId aus Token zu bekommen (optional)
let userId = req.user?.id;
// Falls kein Token vorhanden, versuche userId aus Account zu bekommen (falls E-Mail bekannt)
if (!userId) {
// Kann nicht ohne Authentifizierung arbeiten - Session kann nicht gespeichert werden
return res.status(401).json({
error: 'Authentifizierung erforderlich zum Speichern der Session'
});
}
// Hole die Cookies aus dem Request
const cookies = req.headers.cookie || '';
// Versuche, die Session zu verifizieren, indem wir einen Request mit den Cookies machen
const response = await axios.get('https://www.mytischtennis.de/?_data=root', {
headers: {
'Cookie': cookies,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json'
},
validateStatus: () => true
});
// Prüfe, ob wir eingeloggt sind (durch Prüfung der Response)
if (response.status === 200 && response.data?.userProfile) {
// Session erfolgreich - speichere die Daten
const account = await myTischtennisService.getAccount(userId);
if (!account) {
throw new HttpError('Kein myTischtennis-Account verknüpft', 404);
}
// Extrahiere Cookie-String
const cookieString = cookies.split(';').find(c => c.trim().startsWith('sb-10-auth-token='));
if (!cookieString) {
throw new HttpError('Kein Auth-Token in Cookies gefunden', 400);
}
// Parse Token aus Cookie
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
if (!tokenMatch) {
throw new HttpError('Token-Format ungültig', 400);
}
const base64Token = tokenMatch[1];
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
const tokenData = JSON.parse(decodedToken);
// Aktualisiere Account mit Session-Daten
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
if (myTischtennisAccount) {
myTischtennisAccount.accessToken = tokenData.access_token;
myTischtennisAccount.refreshToken = tokenData.refresh_token;
myTischtennisAccount.expiresAt = tokenData.expires_at;
myTischtennisAccount.cookie = cookieString.trim();
myTischtennisAccount.userData = tokenData.user;
myTischtennisAccount.lastLoginSuccess = new Date();
myTischtennisAccount.lastLoginAttempt = new Date();
// Hole Club-Informationen
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const profileResult = await myTischtennisClient.getUserProfile(cookieString.trim());
if (profileResult.success) {
myTischtennisAccount.clubId = profileResult.clubId;
myTischtennisAccount.clubName = profileResult.clubName;
myTischtennisAccount.fedNickname = profileResult.fedNickname;
}
await myTischtennisAccount.save();
}
res.status(200).json({
success: true,
message: 'Session erfolgreich extrahiert und gespeichert'
});
} else {
throw new HttpError('Nicht eingeloggt oder Session ungültig', 401);
}
} catch (error) {
console.error('Fehler beim Extrahieren der Session:', error);
next(error);
}
}
}
export default new MyTischtennisController();

View File

@@ -33,3 +33,4 @@ CREATE TABLE IF NOT EXISTS `member_training_group` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -37,3 +37,4 @@ const MemberTrainingGroup = sequelize.define('MemberTrainingGroup', {
export default MemberTrainingGroup;

View File

@@ -48,3 +48,4 @@ const TrainingGroup = sequelize.define('TrainingGroup', {
export default TrainingGroup;

View File

@@ -6,7 +6,17 @@ import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
// All routes require authentication
// Login-Page und Login-Submit müssen VOR authenticate stehen, da iframe keinen Token mitsenden kann
// GET /api/mytischtennis/login-page - Proxy für Login-Seite (für iframe)
router.get('/login-page', myTischtennisController.getLoginPage);
// POST /api/mytischtennis/login-submit - Proxy für Login-Form-Submission
router.post('/login-submit', myTischtennisController.submitLogin);
// POST /api/mytischtennis/extract-session - Extrahiere Session nach Login im iframe
router.post('/extract-session', myTischtennisController.extractSession);
// All other routes require authentication
router.use(authenticate);
// GET /api/mytischtennis/account - Get account (alle dürfen lesen)

View File

@@ -168,3 +168,4 @@ migrateMyTischtennisEncryption()
});

View File

@@ -78,6 +78,9 @@ class AutoFetchMatchResultsService {
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
if (loginResult.requiresCaptcha) {
throw new Error(`Re-login failed: CAPTCHA erforderlich. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen.`);
}
throw new Error(`Re-login failed: ${loginResult.error}`);
}

View File

@@ -70,6 +70,9 @@ class AutoUpdateRatingsService {
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
if (loginResult.requiresCaptcha) {
throw new Error(`Re-login failed: CAPTCHA erforderlich. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen.`);
}
throw new Error(`Re-login failed: ${loginResult.error}`);
}

View File

@@ -59,7 +59,12 @@ class MyTischtennisService {
// Login-Versuch bei myTischtennis
loginResult = await myTischtennisClient.login(email, password);
if (!loginResult.success) {
throw new HttpError(loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.', 401);
const statusCode = loginResult.requiresCaptcha ? 400 : 401;
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.';
if (loginResult.requiresCaptcha) {
throw new HttpError({ code: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED', params: { message: errorMessage } }, statusCode);
}
throw new HttpError(errorMessage, statusCode);
}
}
@@ -220,7 +225,7 @@ 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 });
console.log('[myTischtennisService.verifyLogin] Login result:', { success: loginResult.success, error: loginResult.error, requiresCaptcha: loginResult.requiresCaptcha });
if (loginResult.success) {
account.lastLoginSuccess = now;
@@ -256,10 +261,16 @@ class MyTischtennisService {
await account.save(); // Save lastLoginAttempt
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen';
// Verwende den Status-Code vom myTischtennisClient, falls vorhanden, sonst 401
const statusCode = loginResult.status && loginResult.status >= 400 && loginResult.status < 600
? loginResult.status
: 401;
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`);
// Wenn CAPTCHA erforderlich ist, verwende 400 statt 401
const statusCode = loginResult.requiresCaptcha
? 400
: (loginResult.status && loginResult.status >= 400 && loginResult.status < 600
? loginResult.status
: 401);
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`, loginResult.requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
if (loginResult.requiresCaptcha) {
throw new HttpError({ code: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED', params: { message: errorMessage } }, statusCode);
}
throw new HttpError(errorMessage, statusCode);
}
}

View File

@@ -21,95 +21,43 @@ class SchedulerService {
devLog('Starting scheduler service...');
// Schedule automatic rating updates at 6:00 AM daily
// HINWEIS: Automatische MyTischtennis-Abrufe wurden deaktiviert
// Die folgenden Jobs werden nicht mehr ausgeführt:
// - Rating Updates (6:00 AM)
// - Match Results Fetch (6:30 AM)
// Erstelle Dummy-Jobs, damit getStatus() weiterhin funktioniert
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
const startTime = Date.now();
devLog(`[${new Date().toISOString()}] CRON: Executing scheduled rating updates...`);
let errorMessage = null;
try {
// Let the service return details including counts if available
const result = await autoUpdateRatingsService.executeAutomaticUpdates();
const executionTime = Date.now() - startTime;
// result may include updatedCount or a summary object
const messageObj = result && typeof result === 'object' ? result : { message: 'Rating updates completed successfully' };
// Log to ApiLog with rich details
await apiLogService.logSchedulerExecution('rating_updates', true, messageObj, executionTime, null);
devLog('Scheduled rating updates completed successfully');
} catch (error) {
const executionTime = Date.now() - startTime;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled rating updates:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('rating_updates', false, { message: 'Rating updates failed' }, executionTime, errorMessage);
}
devLog('[DISABLED] Rating updates job would run here (deactivated)');
}, {
scheduled: false, // Don't start automatically
scheduled: false,
timezone: 'Europe/Berlin'
});
this.jobs.set('ratingUpdates', ratingUpdateJob);
ratingUpdateJob.start();
devLog('Rating update job scheduled and started');
// Schedule automatic match results fetching at 6:30 AM daily
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
const startTime = Date.now();
devLog(`[${new Date().toISOString()}] CRON: Executing scheduled match results fetch...`);
let errorMessage = null;
try {
// Execute and capture returned summary (should include counts)
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
const executionTime = Date.now() - startTime;
const messageObj = result && typeof result === 'object' ? result : { message: 'Match results fetch completed successfully' };
// Log to ApiLog with rich details (including counts if present)
await apiLogService.logSchedulerExecution('match_results', true, messageObj, executionTime, null);
devLog('Scheduled match results fetch completed successfully');
} catch (error) {
const executionTime = Date.now() - startTime;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled match results fetch:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('match_results', false, { message: 'Match results fetch failed' }, executionTime, errorMessage);
}
devLog('[DISABLED] Match results fetch job would run here (deactivated)');
}, {
scheduled: false, // Don't start automatically
scheduled: false,
timezone: 'Europe/Berlin'
});
// Jobs werden NICHT gestartet (deaktiviert)
this.jobs.set('ratingUpdates', ratingUpdateJob);
this.jobs.set('matchResults', matchResultsJob);
matchResultsJob.start();
devLog('Match results fetch job scheduled and started');
devLog('MyTischtennis automatic fetch jobs are DISABLED');
this.isRunning = true;
const now = new Date();
const tomorrow6AM = new Date(now);
tomorrow6AM.setDate(tomorrow6AM.getDate() + 1);
tomorrow6AM.setHours(6, 0, 0, 0);
const tomorrow630AM = new Date(now);
tomorrow630AM.setDate(tomorrow630AM.getDate() + 1);
tomorrow630AM.setHours(6, 30, 0, 0);
devLog('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
devLog(`[Scheduler] Server time: ${now.toISOString()}`);
devLog(`[Scheduler] Timezone: Europe/Berlin`);
devLog(`[Scheduler] Rating updates: Next execution at ${tomorrow6AM.toISOString()} (6:00 AM Berlin time)`);
devLog(`[Scheduler] Match results fetch: Next execution at ${tomorrow630AM.toISOString()} (6:30 AM Berlin time)`);
devLog(`[Scheduler] MyTischtennis automatic fetch jobs: DISABLED`);
devLog('[Scheduler] =====================================');
devLog('Scheduler service started successfully');
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)');
devLog('MyTischtennis automatic fetch jobs are DISABLED');
}
/**
@@ -146,30 +94,26 @@ class SchedulerService {
/**
* Manually trigger rating updates (for testing)
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerRatingUpdates() {
devLog('Manually triggering rating updates...');
try {
await autoUpdateRatingsService.executeAutomaticUpdates();
return { success: true, message: 'Rating updates completed successfully' };
} catch (error) {
console.error('Error in manual rating updates:', error);
return { success: false, message: error.message };
}
devLog('[DISABLED] Manual rating updates trigger called (deactivated)');
return {
success: false,
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
};
}
/**
* Manually trigger match results fetch (for testing)
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerMatchResultsFetch() {
devLog('Manually triggering match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
return { success: true, message: 'Match results fetch completed successfully' };
} catch (error) {
console.error('Error in manual match results fetch:', error);
return { success: false, message: error.message };
}
devLog('[DISABLED] Manual match results fetch trigger called (deactivated)');
return {
success: false,
message: 'Automatische MyTischtennis-Abrufe wurden deaktiviert'
};
}
/**

View File

@@ -2,74 +2,75 @@
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>{{ account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount') }}</h3>
<h3>{{ loginMode ? $t('myTischtennisDialog.login') : (account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount')) }}</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
<input
type="email"
id="mtt-email"
v-model="formData.email"
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
required
/>
<!-- Im Login-Modus: Zeige MyTischtennis-Login-Formular in iframe -->
<div v-if="loginMode" class="login-iframe-container">
<iframe
ref="loginIframe"
:src="loginUrl"
class="login-iframe"
@load="onIframeLoad"
></iframe>
<div v-if="loading" class="iframe-loading">
{{ $t('myTischtennisDialog.loadingLoginForm') }}
</div>
</div>
<div class="form-group">
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
<input
type="password"
id="mtt-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
/>
</div>
<div class="form-group checkbox-group">
<label>
<!-- Im Bearbeiten-Modus: Zeige normales Formular -->
<template v-else>
<div class="form-group">
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
<input
type="checkbox"
v-model="formData.savePassword"
type="email"
id="mtt-email"
v-model="formData.email"
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
required
/>
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.savePasswordHint') }}
</p>
</div>
<div class="form-group checkbox-group">
<label>
</div>
<div class="form-group">
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
<input
type="checkbox"
v-model="formData.autoUpdateRatings"
:disabled="!formData.savePassword"
type="password"
id="mtt-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
/>
<span>{{ $t('myTischtennisDialog.autoUpdateRatings') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.autoUpdateRatingsHint') }}
</p>
<p v-if="!formData.savePassword" class="warning">
{{ $t('myTischtennisDialog.autoUpdateWarning') }}
</p>
</div>
<div class="form-group" v-if="formData.password">
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
required
/>
<p class="hint">
{{ $t('myTischtennisDialog.appPasswordHint') }}
</p>
</div>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.savePassword"
/>
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.savePasswordHint') }}
</p>
</div>
<!-- Auto-Update-Checkbox entfernt - automatische Abrufe wurden deaktiviert -->
<div class="form-group" v-if="formData.password">
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
required
/>
<p class="hint">
{{ $t('myTischtennisDialog.appPasswordHint') }}
</p>
</div>
</template>
<div v-if="error" class="error-message">
{{ error }}
@@ -80,7 +81,7 @@
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
{{ $t('myTischtennisDialog.cancel') }}
</button>
<button class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
<button v-if="!loginMode" class="btn-primary" @click="saveAccount()" :disabled="!canSave || saving">
{{ saving ? $t('myTischtennisDialog.saving') : $t('myTischtennisDialog.save') }}
</button>
</div>
@@ -97,6 +98,10 @@ export default {
account: {
type: Object,
default: null
},
loginMode: {
type: Boolean,
default: false
}
},
data() {
@@ -105,34 +110,104 @@ export default {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
autoUpdateRatings: this.account?.autoUpdateRatings || false,
autoUpdateRatings: false, // Automatische Updates deaktiviert
userPassword: ''
},
saving: false,
error: null
loading: false,
error: null,
urlCheckInterval: null
};
},
computed: {
loginUrl() {
// Verwende Backend-Proxy für Login-Seite, damit Cookies im Backend-Kontext bleiben
// Verwende absolute URL für iframe
const baseUrl = import.meta.env.VITE_BACKEND || window.location.origin;
// Füge Token als Query-Parameter hinzu, damit Backend userId extrahieren kann
const token = this.$store.state.token;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
return `${baseUrl}/api/mytischtennis/login-page${tokenParam}`;
},
canSave() {
// E-Mail ist erforderlich
if (!this.formData.email.trim()) {
return false;
}
// Im Login-Modus: Passwort ist erforderlich
if (this.loginMode) {
return !!this.formData.password;
}
// Wenn ein Passwort eingegeben wurde, muss auch das App-Passwort eingegeben werden
if (this.formData.password && !this.formData.userPassword) {
return false;
}
// Automatische Updates erfordern gespeichertes Passwort
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
return false;
}
return true;
}
},
mounted() {
if (this.loginMode) {
this.loading = true;
// URL-Überwachung wird erst gestartet, nachdem das iframe geladen wurde
}
},
beforeUnmount() {
// URL-Überwachung stoppen
if (this.urlCheckInterval) {
clearInterval(this.urlCheckInterval);
}
},
methods: {
onIframeLoad() {
this.loading = false;
console.log('[MyTischtennisDialog] Iframe geladen');
// Starte URL-Überwachung erst NACH dem Laden des iframes
// Warte 3 Sekunden, damit der Benutzer Zeit hat, sich einzuloggen
setTimeout(() => {
this.startUrlMonitoring();
}, 3000);
},
checkIframeUrl() {
try {
const iframe = this.$refs.loginIframe;
if (!iframe || !iframe.contentWindow) return;
// Versuche, die URL zu lesen (funktioniert nur bei gleicher Origin)
// Da mytischtennis.de eine andere Origin ist, können wir die URL nicht direkt lesen
// Stattdessen überwachen wir über PostMessage oder Polling
} catch (error) {
// Cross-Origin-Zugriff nicht möglich - das ist normal
console.log('[MyTischtennisDialog] Cross-Origin-Zugriff nicht möglich (erwartet)');
}
},
startUrlMonitoring() {
// Überwache, ob der Login erfolgreich war
// Prüfe, ob bereits eine gültige Session existiert
this.urlCheckInterval = setInterval(async () => {
try {
// Prüfe, ob bereits eine gültige Session existiert
// Nach erfolgreichem Login im iframe sollte submitLogin die Session gespeichert haben
const sessionResponse = await apiClient.get('/mytischtennis/session');
if (sessionResponse.data && sessionResponse.data.session && sessionResponse.data.session.accessToken) {
// Session vorhanden - Login erfolgreich!
clearInterval(this.urlCheckInterval);
this.urlCheckInterval = null;
this.$emit('logged-in');
return;
}
} catch (error) {
// Noch nicht eingeloggt oder Fehler - ignorieren
// (wird alle 3 Sekunden wieder versucht)
}
}, 3000); // Alle 3 Sekunden prüfen
},
async saveAccount() {
if (!this.canSave) return;
@@ -143,7 +218,7 @@ export default {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword,
autoUpdateRatings: this.formData.autoUpdateRatings
autoUpdateRatings: false // Automatische Updates immer deaktiviert
};
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
@@ -191,6 +266,33 @@ export default {
flex-direction: column;
}
.login-iframe-container {
position: relative;
width: 100%;
height: 600px;
min-height: 600px;
border: 1px solid #dee2e6;
border-radius: 4px;
overflow: hidden;
}
.login-iframe {
width: 100%;
height: 100%;
border: none;
}
.iframe-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 1rem;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
z-index: 10;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;

View File

@@ -1155,7 +1155,11 @@
"cancel": "Abbrechen",
"saving": "Speichere...",
"save": "Speichern",
"errorSaving": "Fehler beim Speichern des Accounts"
"errorSaving": "Fehler beim Speichern des Accounts",
"login": "Einloggen",
"loggingIn": "Logge ein...",
"errorLogin": "Fehler beim Einloggen",
"loadingLoginForm": "Lade Login-Formular..."
},
"trainingDetails": {
"title": "Trainings-Details",
@@ -1536,6 +1540,7 @@
"ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED": "Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.",
"ERROR_MYTISCHTENNIS_SESSION_EXPIRED": "Session abgelaufen. Bitte erneut einloggen.",
"ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED": "Kein Passwort gespeichert und Session abgelaufen. Bitte Passwort eingeben.",
"ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED": "CAPTCHA erforderlich. MyTischtennis verwendet jetzt ein CAPTCHA beim Login. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen, oder kontaktieren Sie den Support.",
"ERROR_MEMBER_NOT_FOUND": "Mitglied nicht gefunden.",
"ERROR_MEMBER_ALREADY_EXISTS": "Mitglied existiert bereits.",
"ERROR_MEMBER_FIRSTNAME_REQUIRED": "Vorname ist erforderlich.",

View File

@@ -34,75 +34,12 @@
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
</div>
<div class="info-row" v-if="account.lastUpdateRatings">
<label>{{ $t('myTischtennisAccount.lastFetch') }}</label>
<span>{{ formatDate(account.lastUpdateRatings) }}</span>
</div>
<div class="info-row" v-if="account.autoUpdateRatings !== undefined">
<label>{{ $t('myTischtennisAccount.autoUpdates') }}</label>
<span>{{ account.autoUpdateRatings ? $t('myTischtennisAccount.enabled') : $t('myTischtennisAccount.disabled') }}</span>
</div>
<div class="button-group">
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.editAccount') }}</button>
<button type="button" class="btn-secondary" @click="testConnection">{{ $t('myTischtennisAccount.loginAgain') }}</button>
<button class="btn-info" @click="openHistoryDialog" v-if="account">{{ $t('myTischtennisAccount.updateHistory') }}</button>
<button class="btn-danger" @click="deleteAccount">{{ $t('myTischtennisAccount.unlinkAccount') }}</button>
</div>
</div>
<!-- Fetch Statistics Section -->
<div class="info-section fetch-stats-section" v-if="account">
<h2>{{ $t('myTischtennisAccount.fetchStatistics') }}</h2>
<div v-if="loadingStats" class="loading-stats">{{ $t('myTischtennisAccount.loadingStatistics') }}</div>
<div v-else-if="latestFetches" class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.playerRatings') }}</h3>
<div v-if="latestFetches.ratings">
<p class="stat-date">{{ formatDateRelative(latestFetches.ratings.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.ratings.recordsProcessed }} {{ $t('myTischtennisAccount.playersUpdated') }}</p>
<p class="stat-time" v-if="latestFetches.ratings.executionTime">{{ latestFetches.ratings.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏓</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.matchResults') }}</h3>
<div v-if="latestFetches.match_results">
<p class="stat-date">{{ formatDateRelative(latestFetches.match_results.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.match_results.recordsProcessed }} {{ $t('myTischtennisAccount.results') }}</p>
<p class="stat-time" v-if="latestFetches.match_results.executionTime">{{ latestFetches.match_results.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.leagueTables') }}</h3>
<div v-if="latestFetches.league_table">
<p class="stat-date">{{ formatDateRelative(latestFetches.league_table.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.league_table.recordsProcessed }} {{ $t('myTischtennisAccount.teams') }}</p>
<p class="stat-time" v-if="latestFetches.league_table.executionTime">{{ latestFetches.league_table.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
</div>
<button class="btn-secondary refresh-stats-btn" @click="loadLatestFetches">
🔄 {{ $t('myTischtennisAccount.refreshStatistics') }}
</button>
</div>
</div>
<div v-else class="no-account">
@@ -126,15 +63,12 @@
<MyTischtennisDialog
v-if="showDialog"
:account="account"
:login-mode="loginMode"
@close="closeDialog"
@saved="onAccountSaved"
@logged-in="onLoggedIn"
/>
<!-- History Dialog -->
<MyTischtennisHistoryDialog
v-if="showHistoryDialog"
@close="closeHistoryDialog"
/>
</div>
@@ -163,7 +97,6 @@
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
import MyTischtennisHistoryDialog from '../components/MyTischtennisHistoryDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
@@ -171,7 +104,6 @@ export default {
name: 'MyTischtennisAccount',
components: {
MyTischtennisDialog,
MyTischtennisHistoryDialog,
InfoDialog,
ConfirmDialog
},
@@ -194,17 +126,14 @@ export default {
resolveCallback: null
},
loading: true,
loadingStats: false,
account: null,
accountStatus: null,
latestFetches: null,
showDialog: false,
showHistoryDialog: false
loginMode: false
};
},
mounted() {
this.loadAccount();
this.loadLatestFetches();
},
methods: {
// Dialog Helper Methods
@@ -262,19 +191,13 @@ export default {
},
openEditDialog() {
this.loginMode = false;
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
},
openHistoryDialog() {
this.showHistoryDialog = true;
},
closeHistoryDialog() {
this.showHistoryDialog = false;
this.loginMode = false;
},
async onAccountSaved() {
@@ -286,33 +209,19 @@ export default {
});
},
async onLoggedIn() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.loginSuccessful'),
type: 'success'
});
},
async testConnection() {
console.log('[testConnection] Starting connection test...');
try {
const response = await apiClient.post('/mytischtennis/verify');
console.log('[testConnection] Response:', response);
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.loginSuccessful'),
type: 'success'
});
await this.loadAccount(); // Aktualisiere Account-Daten inkl. clubId, fedNickname
} catch (error) {
console.error('[testConnection] Error:', error);
const message = getSafeErrorMessage(error, 'Login fehlgeschlagen');
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
// Passwort-Dialog öffnen
this.showDialog = true;
this.showInfo(this.$t('myTischtennisAccount.passwordRequired'), message, '', 'warning');
} else {
this.showInfo(this.$t('myTischtennisAccount.loginFailed'), message, '', 'error');
}
this.$store.dispatch('showMessage', {
text: message,
type: 'error'
});
}
// Öffne das Login-Dialog mit vorausgefüllter E-Mail
this.showDialog = true;
this.loginMode = true;
},
async deleteAccount() {
@@ -336,18 +245,6 @@ export default {
}
},
async loadLatestFetches() {
this.loadingStats = true;
try {
const response = await apiClient.get('/mytischtennis/latest-fetches');
this.latestFetches = response.data.latestFetches;
} catch (error) {
console.error('Fehler beim Laden der Fetch-Statistiken:', error);
} finally {
this.loadingStats = false;
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);