From 27665a45df614dfb9a616c940e221eabd8235a03 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 4 Mar 2026 14:26:27 +0100 Subject: [PATCH] feat(myTischtennis): implement session restoration for browser automation login - Enhanced the loginWithBrowserAutomation method to accept an options parameter for restoring a saved Playwright session, allowing users to bypass CAPTCHA if the session is still valid. - Added a new playwrightStorageState field in the MyTischtennis model to store the encrypted browser storage state, facilitating session persistence. - Updated myTischtennisService to utilize the saved storage state during login attempts, improving user experience by reducing unnecessary CAPTCHA challenges. --- backend/clients/myTischtennisClient.js | 63 ++++++++++++++++++++++-- backend/models/MyTischtennis.js | 24 +++++++++ backend/services/myTischtennisService.js | 21 +++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/backend/clients/myTischtennisClient.js b/backend/clients/myTischtennisClient.js index 483034ef..c17de5f6 100644 --- a/backend/clients/myTischtennisClient.js +++ b/backend/clients/myTischtennisClient.js @@ -352,11 +352,62 @@ class MyTischtennisClient { * Browser-based fallback login for CAPTCHA flows. * @param {string} email * @param {string} password - * @returns {Promise} Login response with token and session data + * @param {Object} [options] + * @param {Object} [options.savedStorageState] - Playwright storage state from a previous session. + * If provided and the stored auth cookie is still valid, returns immediately without a new login. + * @returns {Promise} Login response with token, session data, and `storageState` for persistence. */ - async loginWithBrowserAutomation(email, password) { + async loginWithBrowserAutomation(email, password, options = {}) { + const { savedStorageState } = options; let browser = null; let context = null; + + // --- Fast path: restore a saved Playwright session --- + if (savedStorageState) { + try { + browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }); + context = await browser.newContext({ storageState: savedStorageState }); + const cookies = await context.cookies('https://www.mytischtennis.de'); + const authCookie = cookies.find((c) => c.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(c.name)); + if (authCookie?.value) { + const tokenMatch = String(authCookie.value).match(/^base64-(.+)$/); + if (tokenMatch) { + const tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8')); + const nowSec = Math.floor(Date.now() / 1000); + // Accept if not expired (with 5-minute safety buffer) + if (tokenData.expires_at && tokenData.expires_at > nowSec + 300) { + console.log('[myTischtennisClient.playwright] Restored session from saved state (no CAPTCHA needed)'); + const storageState = await context.storageState(); + await context.close(); + await browser.close(); + browser = null; context = null; + return { + success: true, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresAt: tokenData.expires_at, + expiresIn: tokenData.expires_in, + user: tokenData.user, + cookie: `sb-10-auth-token=${authCookie.value}`, + storageState, + restoredFromCache: true + }; + } + } + } + // Cookie absent or expired → close and fall through to full login + console.log('[myTischtennisClient.playwright] Saved session expired or invalid, starting full login'); + await context.close(); + await browser.close(); + browser = null; context = null; + } catch (restoreErr) { + console.warn('[myTischtennisClient.playwright] Session restore failed, starting full login:', restoreErr.message); + try { if (context) await context.close(); } catch (_e) { /* ignore */ } + try { if (browser) await browser.close(); } catch (_e) { /* ignore */ } + browser = null; context = null; + } + } + try { console.log('[myTischtennisClient.playwright] Start browser login flow'); browser = await chromium.launch({ @@ -686,6 +737,11 @@ class MyTischtennisClient { } const cookie = `sb-10-auth-token=${authCookieObj.value}`; + + // Persist the full browser storage state so future calls can skip the CAPTCHA flow. + let storageState = null; + try { storageState = await context.storageState(); } catch (_e) { /* ignore */ } + console.log('[myTischtennisClient.playwright] Browser login successful'); return { success: true, @@ -694,7 +750,8 @@ class MyTischtennisClient { expiresAt: tokenData.expires_at, expiresIn: tokenData.expires_in, user: tokenData.user, - cookie + cookie, + storageState }; } catch (error) { const rawMessage = String(error?.message || error || 'Playwright-Login fehlgeschlagen'); diff --git a/backend/models/MyTischtennis.js b/backend/models/MyTischtennis.js index f361674f..3e0b4212 100644 --- a/backend/models/MyTischtennis.js +++ b/backend/models/MyTischtennis.js @@ -199,6 +199,30 @@ const MyTischtennis = sequelize.define('MyTischtennis', { type: DataTypes.DATE, allowNull: true, field: 'last_update_ratings' + }, + playwrightStorageState: { + // Encrypted JSON blob: full Playwright browser storage state (cookies + localStorage). + // Allows restoring a previous login session without a new CAPTCHA challenge. + type: DataTypes.TEXT('long'), + allowNull: true, + field: 'playwright_storage_state', + set(value) { + if (value === null || value === undefined) { + this.setDataValue('playwrightStorageState', null); + } else { + const jsonString = typeof value === 'string' ? value : JSON.stringify(value); + this.setDataValue('playwrightStorageState', encryptData(jsonString)); + } + }, + get() { + const encrypted = this.getDataValue('playwrightStorageState'); + if (!encrypted) return null; + try { + return JSON.parse(decryptData(encrypted)); + } catch (_e) { + return null; + } + } } }, { underscored: true, diff --git a/backend/services/myTischtennisService.js b/backend/services/myTischtennisService.js index c2bb171e..8a10cfc8 100644 --- a/backend/services/myTischtennisService.js +++ b/backend/services/myTischtennisService.js @@ -60,7 +60,12 @@ class MyTischtennisService { 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); + // Load saved browser state so we can skip the CAPTCHA if the previous session is still valid. + const existingAccount = await MyTischtennis.findOne({ where: { userId } }); + const savedStorageState = existingAccount?.playwrightStorageState ?? null; + const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation( + email, password, { savedStorageState } + ); if (playwrightResult.success) { loginResult = playwrightResult; } else { @@ -106,6 +111,9 @@ class MyTischtennisService { account.expiresAt = loginResult.expiresAt; account.cookie = loginResult.cookie; account.userData = loginResult.user; + if (loginResult.storageState) { + account.playwrightStorageState = loginResult.storageState; + } // Hole Club-ID und Federation const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); @@ -139,6 +147,9 @@ class MyTischtennisService { accountData.expiresAt = loginResult.expiresAt; accountData.cookie = loginResult.cookie; accountData.userData = loginResult.user; + if (loginResult.storageState) { + accountData.playwrightStorageState = loginResult.storageState; + } // Hole Club-ID const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); @@ -242,7 +253,10 @@ class MyTischtennisService { if (!effectiveLoginResult.success && effectiveLoginResult.requiresCaptcha) { console.log('[myTischtennisService.verifyLogin] CAPTCHA-Fehler, versuche Playwright-Fallback...'); try { - const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(account.email, password); + const savedStorageState = account.playwrightStorageState ?? null; + const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation( + account.email, password, { savedStorageState } + ); if (playwrightResult.success) { effectiveLoginResult = playwrightResult; } else { @@ -282,6 +296,9 @@ class MyTischtennisService { account.expiresAt = effectiveLoginResult.expiresAt; account.cookie = effectiveLoginResult.cookie; account.userData = effectiveLoginResult.user; + if (effectiveLoginResult.storageState) { + account.playwrightStorageState = effectiveLoginResult.storageState; + } // Hole Club-ID und Federation const profileResult = await myTischtennisClient.getUserProfile(effectiveLoginResult.cookie);