diff --git a/backend/clients/hettvClient.js b/backend/clients/hettvClient.js new file mode 100644 index 0000000..61f8f86 --- /dev/null +++ b/backend/clients/hettvClient.js @@ -0,0 +1,603 @@ +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; + +const BASE_URL = 'https://ttde-id.liga.nu'; +const CLICK_TT_BASE = 'https://httv.click-tt.de'; + +class HettvClient { + constructor() { + this.baseURL = BASE_URL; + this.client = axios.create({ + baseURL: this.baseURL, + timeout: 15000, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + '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' + }, + maxRedirects: 5, // Folge den OAuth2-Redirects + validateStatus: (status) => status >= 200 && status < 400 + }); + + // Einfache Cookie-Jar nach Host -> { name: value } + this.cookieJar = new Map(); + this.defaultHeaders = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0', + 'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7' + }; + } + + /** + * Login to HeTTV via OAuth2 + * @param {string} username - HeTTV username (email) + * @param {string} password - HeTTV password + * @returns {Promise} Login response with session data + */ + async login(username, password) { + try { + console.log('[HettvClient] - Starting login for:', username); + + // Schritt 1: OAuth2-Authorization-Endpoint aufrufen - das sollte zur Login-Seite weiterleiten + const oauthParams = new URLSearchParams({ + 'scope': 'nuLiga', + 'response_type': 'code', + 'redirect_uri': 'https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/oAuthLogin', + 'state': 'nonce=' + Math.random().toString(36).substring(2, 15), + 'client_id': 'XtVpGjXKAhz3BZuu' + }); + + // OAuth2 Start + + // Der OAuth2-Endpoint sollte direkt zur Login-Seite weiterleiten + const loginPageResponse = await this.client.get(`/oauth2/authz/ttde?${oauthParams.toString()}`, { + maxRedirects: 5, // Folge den Redirects zur Login-Seite + validateStatus: (status) => status >= 200 && status < 400, + headers: { + ...this.defaultHeaders + } + }); + + // Login-Seite erreicht + + // Session-Cookie aus der Login-Seite extrahieren + const setCookies = loginPageResponse.headers['set-cookie']; + if (!setCookies || !Array.isArray(setCookies)) { + console.error('[HettvClient] - No cookies from login page'); + return { + success: false, + error: 'Keine Session-Cookie von Login-Seite erhalten' + }; + } + + const sessionCookie = setCookies.find(cookie => cookie.startsWith('nusportingress=')); + if (!sessionCookie) { + console.error('[HettvClient] - No nusportingress cookie from login page'); + return { + success: false, + error: 'Keine nusportingress Session von Login-Seite erhalten' + }; + } + + // Extrahiere t:formdata aus dem HTML der Login-Seite + const htmlContent = loginPageResponse.data; + // HTML erhalten + + // Suche nach t:formdata im HTML - verschiedene mögliche Formate + let formDataMatch = htmlContent.match(/name="t:formdata"\s+value="([^"]+)"/); + + if (!formDataMatch) { + // Versuche andere Formate + formDataMatch = htmlContent.match(/name='t:formdata'\s+value='([^']+)'/); + } + + if (!formDataMatch) { + // Suche nach hidden input mit t:formdata (value vor name) + formDataMatch = htmlContent.match(/]*value="([^"]+)"[^>]*name="t:formdata"/); + } + + if (!formDataMatch) { + // Suche nach hidden input mit t:formdata (name vor value) + formDataMatch = htmlContent.match(/]*name="t:formdata"[^>]*value="([^"]+)"/); + } + + if (!formDataMatch) { + // Suche nach t:formdata ohne Anführungszeichen + formDataMatch = htmlContent.match(/name=t:formdata\s+value=([^\s>]+)/); + } + + if (!formDataMatch) { + console.error('[HettvClient] - No t:formdata found in login page'); + console.log('[HettvClient] - HTML snippet:', htmlContent.substring(0, 2000)); + + // Debug: Suche nach allen hidden inputs + const hiddenInputs = htmlContent.match(/]*type="hidden"[^>]*>/g); + console.log('[HettvClient] - Hidden inputs found:', hiddenInputs); + + return { + success: false, + error: 'Keine t:formdata von Login-Seite erhalten' + }; + } + + const tFormData = formDataMatch[1]; + // CSRF-Token gefunden + + // Schritt 2: Login mit den korrekten Daten durchführen + // Verwende die Session-Cookie für den Login-Request + const formData = new URLSearchParams(); + formData.append('t:submit', '["submit_0","submit_0"]'); + formData.append('t:ac', 'ttde'); + formData.append('t:formdata', tFormData); + formData.append('username', username); + formData.append('password', password); + + const loginResponse = await this.client.post('/oauth2/login.loginform', formData.toString(), { + headers: { + 'Cookie': sessionCookie.split(';')[0], + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + ...this.defaultHeaders, + 'Referer': `${BASE_URL}/oauth2/login.loginform` + }, + maxRedirects: 5, + validateStatus: (status) => status >= 200 && status < 400 + }); + + // Login-Antwort erhalten + + // Prüfe ob wir erfolgreich eingeloggt sind + // Login-Response geprüft + + // Prüfe den Response-Inhalt um zu sehen ob wir noch auf der Login-Seite sind + const responseContent = loginResponse.data; + const isLoginPage = responseContent.includes('click-TT ID') && + responseContent.includes('Username') && + responseContent.includes('Password'); + + // Login-Page-Erkennung durchgeführt + + if (isLoginPage) { + console.log('[HettvClient] - Still on login page, login failed'); + console.log('[HettvClient] - Response snippet:', responseContent.substring(0, 500)); + return { + success: false, + error: 'Login fehlgeschlagen - ungültige Zugangsdaten' + }; + } + + // Prüfe auf OAuth2-Redirect oder Erfolg + const hasOAuthRedirect = responseContent.includes('oauth2') || + responseContent.includes('redirect') || + loginResponse.status >= 300; + + // OAuth Redirect erkannt + + // Extrahiere die finale Session-Cookie + const finalCookies = loginResponse.headers['set-cookie']; + const finalSessionCookie = finalCookies?.find(cookie => cookie.startsWith('nusportingress=')); + + const sessionId = (finalSessionCookie || sessionCookie).match(/nusportingress=([^;]+)/)?.[1]; + + console.log('[HettvClient] - Login erfolgreich (HeTTV).'); + + // Versuche die finale OAuth-Weiterleitung zu httv.click-tt.de aufzurufen, um PHPSESSID zu erhalten + let finalUrl = loginResponse.request?.res?.responseUrl; + console.log('[HettvClient] - Login finalUrl:', finalUrl); + let phpSessIdCookie = null; + let finalHtml = null; + try { + if (finalUrl && finalUrl.includes('oAuthLogin')) { + const clickTTClient = axios.create({ + timeout: 15000, + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 400 + }); + + // Folge der Redirect-Kette manuell, übernehme Cookies + let currentUrl = finalUrl; + let lastResp = null; + let hop = 0; + const maxHops = 10; + while (hop++ < maxHops && currentUrl) { + lastResp = await clickTTClient.get(currentUrl, { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + ...this.defaultHeaders, + 'Referer': hop === 1 ? `${BASE_URL}/oauth2/login.loginform` : (lastResp?.request?.res?.responseUrl || currentUrl), + 'Cookie': this._cookieHeaderForUrl(currentUrl) + } + }); + + this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']); + + const loc = lastResp.headers['location']; + if (loc) { + // Absolut vs relativ + if (/^https?:\/\//i.test(loc)) { + currentUrl = loc; + } else { + const u = new URL(currentUrl); + currentUrl = `${u.origin}${loc}`; + } + continue; + } + break; // keine weitere Location => final + } + + const clickTTResp = lastResp; + finalHtml = typeof clickTTResp.data === 'string' ? clickTTResp.data : ''; + const ctSetCookies = clickTTResp.headers['set-cookie']; + if (Array.isArray(ctSetCookies)) { + phpSessIdCookie = ctSetCookies.find(c => c.startsWith('PHPSESSID='))?.split(';')[0] || null; + } + // Finale click-TT URL ermittelt + } + } catch (e) { + // Finale click-TT Seite konnte nicht geladen werden + } + + // Baue kombinierte Cookie-Kette (falls PHPSESSID vorhanden) + const baseCookie = (finalSessionCookie || sessionCookie).split(';')[0]; + const combinedCookie = phpSessIdCookie ? `${baseCookie}; ${phpSessIdCookie}` : baseCookie; + + return { + success: true, + sessionId: sessionId, + cookie: combinedCookie, + accessToken: null, + refreshToken: null, + expiresAt: null, + user: { + finalUrl: finalUrl || null, + htmlSnippet: finalHtml ? finalHtml.substring(0, 2000) : null + } + }; + } catch (error) { + console.error('HeTTV login error:', error.message); + console.error('Error details:', error.response?.status, error.response?.statusText); + return { + success: false, + error: error.response?.data?.message || 'Login fehlgeschlagen', + status: error.response?.status || 500 + }; + } + } + + /** + * Verify login credentials + * @param {string} username - HeTTV username + * @param {string} password - HeTTV password + * @returns {Promise} True if credentials are valid + */ + async verifyCredentials(username, password) { + const result = await this.login(username, password); + return result.success; + } + + /** + * Make an authenticated request to click-TT + * @param {string} endpoint - API endpoint + * @param {string} cookie - JSESSIONID cookie + * @param {Object} options - Additional axios options + * @returns {Promise} API response + */ + async authenticatedRequest(endpoint, cookie, options = {}, finalUrl = null) { + try { + // Bestimme Basis-URL dynamisch aus finalUrl, falls vorhanden + let baseURL = CLICK_TT_BASE; + if (finalUrl) { + try { + const url = new URL(finalUrl); + baseURL = url.origin; + } catch (_) {} + } + + const isAbsolute = /^https?:\/\//i.test(endpoint); + const client = axios.create({ + baseURL: isAbsolute ? undefined : baseURL, + timeout: 15000, + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 400 + }); + + // Manuelles Redirect-Following inkl. Cookies/Referer + let currentUrl = isAbsolute ? endpoint : `${baseURL}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`; + let lastResp = null; + const trace = []; + let hop = 0; + const maxHops = 10; + + console.log(`[HettvClient] - Starting redirect chain from: ${currentUrl}`); + + while (hop++ < maxHops && currentUrl) { + console.log(`[HettvClient] - Redirect ${hop}: GET ${currentUrl}`); + + lastResp = await client.request({ + method: options.method || 'GET', + url: currentUrl, + data: options.data, + headers: { + ...this.defaultHeaders, + ...(options.headers || {}), + 'Cookie': this._mergeCookieHeader(cookie, this._cookieHeaderForUrl(currentUrl)), + 'Referer': hop === 1 ? (finalUrl || baseURL) : (lastResp?.request?.res?.responseUrl || currentUrl) + } + }); + + this._ingestSetCookiesFromResponse(currentUrl, lastResp.headers['set-cookie']); + const loc = lastResp.headers['location']; + + console.log(`[HettvClient] - Response: ${lastResp.status} ${lastResp.statusText}`); + console.log(`[HettvClient] - Location header: ${loc || 'none'}`); + console.log(`[HettvClient] - Set-Cookie header: ${lastResp.headers['set-cookie'] ? 'present' : 'none'}`); + console.log(`[HettvClient] - Content-Type: ${lastResp.headers['content-type'] || 'none'}`); + + // Speichere jede Seite zur Analyse + try { + const dir = path.resolve(process.cwd(), 'backend', 'uploads'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const filename = `hettv_redirect_${hop}_${Date.now()}.html`; + const filePath = path.join(dir, filename); + const content = typeof lastResp.data === 'string' ? lastResp.data : JSON.stringify(lastResp.data, null, 2); + fs.writeFileSync(filePath, content, 'utf8'); + console.log(`[HettvClient] - Saved page to: ${filename}`); + } catch (e) { + console.log(`[HettvClient] - Could not save page ${hop}:`, e.message); + } + + trace.push({ + url: currentUrl, + status: lastResp.status, + location: loc || null + }); + + if (loc) { + const newUrl = /^https?:\/\//i.test(loc) ? loc : `${new URL(currentUrl).origin}${loc}`; + console.log(`[HettvClient] - Following redirect to: ${newUrl}`); + currentUrl = newUrl; + continue; + } + + console.log(`[HettvClient] - Final response: ${lastResp.status} (no more redirects)`); + break; + } + + return { + success: true, + data: lastResp?.data, + trace + }; + } catch (error) { + console.error('HeTTV API error:', error.message); + return { + success: false, + error: error.response?.data?.message || 'API-Anfrage fehlgeschlagen', + status: error.response?.status || 500 + }; + } + } + + /** + * Navigate to main HeTTV page and find Downloads menu + * @param {string} cookie - Session cookie + * @returns {Promise} Response with main page content and download links + */ + async getMainPageWithDownloads(cookie, finalUrl = null) { + try { + console.log('[HettvClient] - Loading main HeTTV page...'); + + // Kandidaten für Einstiegs-URL bestimmen + let origin = CLICK_TT_BASE; + if (finalUrl) { + try { origin = new URL(finalUrl).origin; } catch (_) {} + } + + const candidates = []; + // Direkt zu HeTTV navigieren + candidates.push('http://httv.click-tt.de/'); + candidates.push('http://httv.click-tt.de/wa/'); + candidates.push('http://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/'); + + // Wenn wir eine finalUrl haben, verwende diese auch + if (finalUrl) { + candidates.push(finalUrl); + } + + console.log('[HettvClient] - URL candidates:', candidates); + + let mainPageResponse = null; + let mainTrace = []; + let lastError = null; + for (const candidate of candidates) { + const resp = await this.authenticatedRequest(candidate, cookie, {}, finalUrl); + if (resp.success && typeof resp.data === 'string' && resp.data.length > 0) { + mainPageResponse = resp; + mainTrace = resp.trace || []; + break; + } + lastError = resp; + } + + if (!mainPageResponse) { + return lastError || { success: false, error: 'HeTTV Einstiegsseite nicht erreichbar', status: 404 }; + } + + const htmlContent = mainPageResponse.data; + console.log('[HettvClient] - Main page loaded, HTML length:', htmlContent.length); + + // Erkenne Fehlerseite (Session ungültig) + if (/click-TT\s*-\s*Fehlerseite/i.test(htmlContent) || /ungültige oder nicht mehr gültige URL/i.test(htmlContent)) { + return { + success: false, + error: 'Session ungültig oder abgelaufen', + status: 401, + data: { htmlSnippet: htmlContent.substring(0, 1000) } + }; + } + + // Speichere HTML zur Analyse + let savedFile = null; + try { + const dir = path.resolve(process.cwd(), 'backend', 'uploads'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const filename = `hettv_main_${Date.now()}.html`; + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, htmlContent, 'utf8'); + savedFile = filePath; + } catch (e) { + // Ignoriere Speicherfehler still, nur für Debug + } + + // Suche nach Downloads-Links im HTML + const downloadLinks = []; + + // 1) URL-Heuristiken + const urlPatterns = [ + /href="([^"]*download[^"]*)"/gi, + /href="([^"]*downloads[^"]*)"/gi, + /href="([^"]*Download[^"]*)"/gi, + /href="([^"]*Downloads[^"]*)"/gi + ]; + + urlPatterns.forEach(pattern => { + let match; + while ((match = pattern.exec(htmlContent)) !== null) { + const link = match[1]; + if (link && !downloadLinks.includes(link)) { + downloadLinks.push(link); + } + } + }); + + // 2) Linktext-Heuristik: Downloads + const anchorPattern = /]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi; + let aMatch; + while ((aMatch = anchorPattern.exec(htmlContent)) !== null) { + const href = aMatch[1]; + const text = aMatch[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + if (/\bdownloads?\b/i.test(text)) { + if (href && !downloadLinks.includes(href)) { + downloadLinks.push(href); + } + } + } + + // 3) Fallback: Menüpunkte in Navigationen (role="navigation" etc.) + if (downloadLinks.length === 0) { + const navSectionRegex = //gi; + let nav; + while ((nav = navSectionRegex.exec(htmlContent)) !== null) { + const section = nav[0]; + let m; + anchorPattern.lastIndex = 0; + while ((m = anchorPattern.exec(section)) !== null) { + const href = m[1]; + const text = m[2].replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + if (/\bdownloads?\b/i.test(text)) { + if (href && !downloadLinks.includes(href)) { + downloadLinks.push(href); + } + } + } + } + } + + console.log('[HettvClient] - Found download links:', downloadLinks); + + return { + success: true, + data: { + htmlContent: htmlContent, + downloadLinks: downloadLinks, + htmlSnippet: htmlContent.substring(0, 2000), // Erste 2000 Zeichen für Analyse + savedFile, + trace: mainTrace, + lastUrl: mainTrace.length ? mainTrace[mainTrace.length - 1].url : null, + lastStatus: mainTrace.length ? mainTrace[mainTrace.length - 1].status : null + } + }; + } catch (error) { + console.error('HeTTV main page error:', error.message); + return { + success: false, + error: error.message || 'Fehler beim Laden der Hauptseite', + status: 500 + }; + } + } + + /** + * Load a specific download page + * @param {string} downloadUrl - URL to the download page + * @param {string} cookie - Session cookie + * @returns {Promise} Response with download page content + */ + async loadDownloadPage(downloadUrl, cookie, finalUrl = null) { + try { + console.log('[HettvClient] - Loading download page:', downloadUrl); + + const response = await this.authenticatedRequest(downloadUrl, cookie, {}, finalUrl); + if (!response.success) { + return response; + } + + const htmlContent = response.data; + console.log('[HettvClient] - Download page loaded, HTML length:', htmlContent.length); + + return { + success: true, + data: { + url: downloadUrl, + htmlContent: htmlContent, + htmlSnippet: htmlContent.substring(0, 3000) // Erste 3000 Zeichen für Analyse + } + }; + } catch (error) { + console.error('HeTTV download page error:', error.message); + return { + success: false, + error: error.message || 'Fehler beim Laden der Download-Seite', + status: 500 + }; + } + } + + // --- Cookie-Helfer --- + _ingestSetCookiesFromResponse(currentUrl, setCookies) { + if (!Array.isArray(setCookies) || setCookies.length === 0) return; + const { host } = new URL(currentUrl); + if (!this.cookieJar.has(host)) this.cookieJar.set(host, new Map()); + const jar = this.cookieJar.get(host); + setCookies.forEach((cookieStr) => { + const pair = cookieStr.split(';')[0]; + const eq = pair.indexOf('='); + if (eq > 0) { + const name = pair.substring(0, eq).trim(); + const value = pair.substring(eq + 1).trim(); + jar.set(name, value); + } + }); + } + + _cookieHeaderForUrl(currentUrl) { + const { host } = new URL(currentUrl); + const jar = this.cookieJar.get(host); + if (!jar || jar.size === 0) return ''; + return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; '); + } + + _mergeCookieHeader(primary, secondary) { + const items = []; + if (primary) items.push(primary); + if (secondary) items.push(secondary); + return items.filter(Boolean).join('; '); + } +} + +export default new HettvClient(); + diff --git a/backend/controllers/externalServiceController.js b/backend/controllers/externalServiceController.js new file mode 100644 index 0000000..e7a68af --- /dev/null +++ b/backend/controllers/externalServiceController.js @@ -0,0 +1,172 @@ +import externalServiceService from '../services/externalServiceService.js'; +import HttpError from '../exceptions/HttpError.js'; + +class ExternalServiceController { + /** + * GET /api/mytischtennis/account?service=mytischtennis + * Get current user's external service account + */ + async getAccount(req, res, next) { + try { + const userId = req.user.id; + const service = req.query.service || 'mytischtennis'; + const account = await externalServiceService.getAccount(userId, service); + + if (!account) { + return res.status(200).json({ account: null }); + } + + res.status(200).json({ account }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/mytischtennis/status?service=mytischtennis + * Check account configuration status + */ + async getStatus(req, res, next) { + try { + const userId = req.user.id; + const service = req.query.service || 'mytischtennis'; + const status = await externalServiceService.checkAccountStatus(userId, service); + res.status(200).json(status); + } catch (error) { + next(error); + } + } + + /** + * POST /api/mytischtennis/account + * Create or update external service account + */ + async upsertAccount(req, res, next) { + try { + const userId = req.user.id; + const { email, password, savePassword, userPassword, service = 'mytischtennis' } = req.body; + + if (!email) { + throw new HttpError(400, 'E-Mail-Adresse erforderlich'); + } + + // Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden + if (password && !userPassword) { + throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts'); + } + + const account = await externalServiceService.upsertAccount( + userId, + email, + password, + savePassword || false, + userPassword, + service + ); + + res.status(200).json({ + message: `${service}-Account erfolgreich gespeichert`, + account + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /api/mytischtennis/account?service=mytischtennis + * Delete external service account + */ + async deleteAccount(req, res, next) { + try { + const userId = req.user.id; + const service = req.query.service || 'mytischtennis'; + const deleted = await externalServiceService.deleteAccount(userId, service); + + if (!deleted) { + throw new HttpError(404, `Kein ${service}-Account gefunden`); + } + + res.status(200).json({ message: `${service}-Account gelöscht` }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/mytischtennis/verify + * Verify login credentials + */ + async verifyLogin(req, res, next) { + try { + const userId = req.user.id; + const { password, service = 'mytischtennis' } = req.body; + + const result = await externalServiceService.verifyLogin(userId, password, service); + + res.status(200).json({ + message: 'Login erfolgreich', + success: true, + accessToken: result.accessToken, + expiresAt: result.expiresAt, + clubId: result.clubId, + clubName: result.clubName + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/mytischtennis/session?service=mytischtennis + * Get stored session data for authenticated requests + */ + async getSession(req, res, next) { + try { + const userId = req.user.id; + const service = req.query.service || 'mytischtennis'; + const session = await externalServiceService.getSession(userId, service); + + res.status(200).json({ session }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/external-service/hettv/main-page + * Load HeTTV main page and find download links + */ + async loadHettvMainPage(req, res, next) { + try { + const userId = req.user.id; + const result = await externalServiceService.loadHettvMainPage(userId); + res.status(200).json(result); + } catch (error) { + next(error); + } + } + + /** + * POST /api/external-service/hettv/download-page + * Load specific HeTTV download page + */ + async loadHettvDownloadPage(req, res, next) { + try { + const userId = req.user.id; + const { downloadUrl } = req.body; + + if (!downloadUrl) { + throw new HttpError(400, 'Download-URL ist erforderlich'); + } + + const result = await externalServiceService.loadHettvDownloadPage(userId, downloadUrl); + res.status(200).json(result); + } catch (error) { + next(error); + } + } +} + +export default new ExternalServiceController(); + diff --git a/backend/controllers/myTischtennisController.js b/backend/controllers/myTischtennisController.js deleted file mode 100644 index 9c8d92c..0000000 --- a/backend/controllers/myTischtennisController.js +++ /dev/null @@ -1,133 +0,0 @@ -import myTischtennisService from '../services/myTischtennisService.js'; -import HttpError from '../exceptions/HttpError.js'; - -class MyTischtennisController { - /** - * GET /api/mytischtennis/account - * Get current user's myTischtennis account - */ - async getAccount(req, res, next) { - try { - const userId = req.user.id; - const account = await myTischtennisService.getAccount(userId); - - if (!account) { - return res.status(200).json({ account: null }); - } - - res.status(200).json({ account }); - } catch (error) { - next(error); - } - } - - /** - * GET /api/mytischtennis/status - * Check account configuration status - */ - async getStatus(req, res, next) { - try { - const userId = req.user.id; - const status = await myTischtennisService.checkAccountStatus(userId); - res.status(200).json(status); - } catch (error) { - next(error); - } - } - - /** - * POST /api/mytischtennis/account - * Create or update myTischtennis account - */ - async upsertAccount(req, res, next) { - try { - const userId = req.user.id; - const { email, password, savePassword, userPassword } = req.body; - - if (!email) { - throw new HttpError(400, 'E-Mail-Adresse erforderlich'); - } - - // Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden - if (password && !userPassword) { - throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts'); - } - - const account = await myTischtennisService.upsertAccount( - userId, - email, - password, - savePassword || false, - userPassword - ); - - res.status(200).json({ - message: 'myTischtennis-Account erfolgreich gespeichert', - account - }); - } catch (error) { - next(error); - } - } - - /** - * DELETE /api/mytischtennis/account - * Delete myTischtennis account - */ - async deleteAccount(req, res, next) { - try { - const userId = req.user.id; - const deleted = await myTischtennisService.deleteAccount(userId); - - if (!deleted) { - throw new HttpError(404, 'Kein myTischtennis-Account gefunden'); - } - - res.status(200).json({ message: 'myTischtennis-Account gelöscht' }); - } catch (error) { - next(error); - } - } - - /** - * POST /api/mytischtennis/verify - * Verify login credentials - */ - async verifyLogin(req, res, next) { - try { - const userId = req.user.id; - const { password } = req.body; - - const result = await myTischtennisService.verifyLogin(userId, password); - - res.status(200).json({ - message: 'Login erfolgreich', - success: true, - accessToken: result.accessToken, - expiresAt: result.expiresAt, - clubId: result.clubId, - clubName: result.clubName - }); - } catch (error) { - next(error); - } - } - - /** - * GET /api/mytischtennis/session - * Get stored session data for authenticated requests - */ - async getSession(req, res, next) { - try { - const userId = req.user.id; - const session = await myTischtennisService.getSession(userId); - - res.status(200).json({ session }); - } catch (error) { - next(error); - } - } -} - -export default new MyTischtennisController(); - diff --git a/backend/models/ExternalServiceAccount.js b/backend/models/ExternalServiceAccount.js new file mode 100644 index 0000000..58e4780 --- /dev/null +++ b/backend/models/ExternalServiceAccount.js @@ -0,0 +1,132 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import { encryptData, decryptData } from '../utils/encrypt.js'; + +const ExternalServiceAccount = sequelize.define('ExternalServiceAccount', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id' + }, + onDelete: 'CASCADE' + }, + service: { + type: DataTypes.STRING(50), + allowNull: false, + defaultValue: 'mytischtennis' + }, + email: { + type: DataTypes.STRING, + allowNull: false, + }, + encryptedPassword: { + type: DataTypes.TEXT, + allowNull: true, + field: 'encrypted_password' + }, + savePassword: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + field: 'save_password' + }, + accessToken: { + type: DataTypes.TEXT, + allowNull: true, + field: 'access_token' + }, + refreshToken: { + type: DataTypes.TEXT, + allowNull: true, + field: 'refresh_token' + }, + expiresAt: { + type: DataTypes.BIGINT, + allowNull: true, + field: 'expires_at' + }, + cookie: { + type: DataTypes.TEXT, + allowNull: true + }, + userData: { + type: DataTypes.JSON, + allowNull: true, + field: 'user_data' + }, + clubId: { + type: DataTypes.STRING, + allowNull: true, + field: 'club_id' + }, + clubName: { + type: DataTypes.STRING, + allowNull: true, + field: 'club_name' + }, + fedNickname: { + type: DataTypes.STRING, + allowNull: true, + field: 'fed_nickname' + }, + lastLoginAttempt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_login_attempt' + }, + lastLoginSuccess: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_login_success' + } +}, { + underscored: true, + tableName: 'external_service_account', + timestamps: true, + indexes: [ + { + unique: true, + fields: ['user_id', 'service'] + } + ], + hooks: { + beforeSave: async (instance) => { + // Wenn savePassword false ist, password auf null setzen + if (!instance.savePassword) { + instance.encryptedPassword = null; + } + } + } +}); + +// Virtuelle Felder für password handling +ExternalServiceAccount.prototype.setPassword = function(password) { + if (password && this.savePassword) { + this.encryptedPassword = encryptData(password); + } else { + this.encryptedPassword = null; + } +}; + +ExternalServiceAccount.prototype.getPassword = function() { + if (this.encryptedPassword) { + try { + return decryptData(this.encryptedPassword); + } catch (error) { + console.error('Error decrypting password:', error); + return null; + } + } + return null; +}; + +export default ExternalServiceAccount; + diff --git a/backend/models/MyTischtennis.js b/backend/models/MyTischtennis.js index 5517249..16d874c 100644 --- a/backend/models/MyTischtennis.js +++ b/backend/models/MyTischtennis.js @@ -2,7 +2,7 @@ import { DataTypes } from 'sequelize'; import sequelize from '../database.js'; import { encryptData, decryptData } from '../utils/encrypt.js'; -const MyTischtennis = sequelize.define('MyTischtennis', { +const ExternalServiceAccount = sequelize.define('ExternalServiceAccount', { id: { type: DataTypes.INTEGER, primaryKey: true, @@ -12,13 +12,17 @@ const MyTischtennis = sequelize.define('MyTischtennis', { userId: { type: DataTypes.INTEGER, allowNull: false, - unique: true, references: { model: 'user', key: 'id' }, onDelete: 'CASCADE' }, + service: { + type: DataTypes.STRING(50), + allowNull: false, + defaultValue: 'mytischtennis' + }, email: { type: DataTypes.STRING, allowNull: false, @@ -85,8 +89,14 @@ const MyTischtennis = sequelize.define('MyTischtennis', { } }, { underscored: true, - tableName: 'my_tischtennis', + tableName: 'external_service_account', timestamps: true, + indexes: [ + { + unique: true, + fields: ['user_id', 'service'] + } + ], hooks: { beforeSave: async (instance) => { // Wenn savePassword false ist, password auf null setzen @@ -98,7 +108,7 @@ const MyTischtennis = sequelize.define('MyTischtennis', { }); // Virtuelle Felder für password handling -MyTischtennis.prototype.setPassword = function(password) { +ExternalServiceAccount.prototype.setPassword = function(password) { if (password && this.savePassword) { this.encryptedPassword = encryptData(password); } else { @@ -106,17 +116,17 @@ MyTischtennis.prototype.setPassword = function(password) { } }; -MyTischtennis.prototype.getPassword = function() { +ExternalServiceAccount.prototype.getPassword = function() { if (this.encryptedPassword) { try { return decryptData(this.encryptedPassword); } catch (error) { - console.error('Error decrypting myTischtennis password:', error); + console.error('Error decrypting password:', error); return null; } } return null; }; -export default MyTischtennis; +export default ExternalServiceAccount; diff --git a/backend/models/index.js b/backend/models/index.js index 993601a..4ee6c62 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -33,7 +33,7 @@ import UserToken from './UserToken.js'; import OfficialTournament from './OfficialTournament.js'; import OfficialCompetition from './OfficialCompetition.js'; import OfficialCompetitionMember from './OfficialCompetitionMember.js'; -import MyTischtennis from './MyTischtennis.js'; +import ExternalServiceAccount from './ExternalServiceAccount.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -205,8 +205,8 @@ Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' }); Accident.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDates' }); DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' }); -User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' }); -MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' }); +User.hasMany(ExternalServiceAccount, { foreignKey: 'userId', as: 'externalServiceAccounts' }); +ExternalServiceAccount.belongsTo(User, { foreignKey: 'userId', as: 'user' }); export { User, @@ -243,5 +243,5 @@ export { OfficialTournament, OfficialCompetition, OfficialCompetitionMember, - MyTischtennis, + ExternalServiceAccount, }; diff --git a/backend/routes/externalServiceRoutes.js b/backend/routes/externalServiceRoutes.js new file mode 100644 index 0000000..889e271 --- /dev/null +++ b/backend/routes/externalServiceRoutes.js @@ -0,0 +1,36 @@ +import express from 'express'; +import externalServiceController from '../controllers/externalServiceController.js'; +import { authenticate } from '../middleware/authMiddleware.js'; + +const router = express.Router(); + +// All routes require authentication +router.use(authenticate); + +// GET /api/external-service/account?service=mytischtennis - Get account +router.get('/account', externalServiceController.getAccount); + +// GET /api/external-service/status?service=mytischtennis - Check status +router.get('/status', externalServiceController.getStatus); + +// POST /api/external-service/account - Create or update account +router.post('/account', externalServiceController.upsertAccount); + +// DELETE /api/external-service/account?service=mytischtennis - Delete account +router.delete('/account', externalServiceController.deleteAccount); + +// POST /api/external-service/verify - Verify login +router.post('/verify', externalServiceController.verifyLogin); + +// GET /api/external-service/session?service=mytischtennis - Get stored session +router.get('/session', externalServiceController.getSession); + +// HeTTV specific routes +// GET /api/external-service/hettv/main-page - Load HeTTV main page and find downloads +router.get('/hettv/main-page', externalServiceController.loadHettvMainPage); + +// POST /api/external-service/hettv/download-page - Load specific HeTTV download page +router.post('/hettv/download-page', externalServiceController.loadHettvDownloadPage); + +export default router; + diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js deleted file mode 100644 index 2a3c668..0000000 --- a/backend/routes/myTischtennisRoutes.js +++ /dev/null @@ -1,29 +0,0 @@ -import express from 'express'; -import myTischtennisController from '../controllers/myTischtennisController.js'; -import { authenticate } from '../middleware/authMiddleware.js'; - -const router = express.Router(); - -// All routes require authentication -router.use(authenticate); - -// GET /api/mytischtennis/account - Get account -router.get('/account', myTischtennisController.getAccount); - -// GET /api/mytischtennis/status - Check status -router.get('/status', myTischtennisController.getStatus); - -// POST /api/mytischtennis/account - Create or update account -router.post('/account', myTischtennisController.upsertAccount); - -// DELETE /api/mytischtennis/account - Delete account -router.delete('/account', myTischtennisController.deleteAccount); - -// POST /api/mytischtennis/verify - Verify login -router.post('/verify', myTischtennisController.verifyLogin); - -// GET /api/mytischtennis/session - Get stored session -router.get('/session', myTischtennisController.getSession); - -export default router; - diff --git a/backend/services/externalServiceService.js b/backend/services/externalServiceService.js new file mode 100644 index 0000000..426196d --- /dev/null +++ b/backend/services/externalServiceService.js @@ -0,0 +1,347 @@ +import ExternalServiceAccount from '../models/ExternalServiceAccount.js'; +import User from '../models/User.js'; +import myTischtennisClient from '../clients/myTischtennisClient.js'; +import hettvClient from '../clients/hettvClient.js'; +import HttpError from '../exceptions/HttpError.js'; + +class ExternalServiceService { + /** + * Get the appropriate client for a service + */ + getClientForService(service) { + switch (service) { + case 'mytischtennis': + return myTischtennisClient; + case 'hettv': + return hettvClient; + default: + throw new HttpError(400, `Unbekannter Service: ${service}`); + } + } + + /** + * Get account for user and service + */ + async getAccount(userId, service = 'mytischtennis') { + const account = await ExternalServiceAccount.findOne({ + where: { userId, service }, + attributes: ['id', 'service', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt'] + }); + return account; + } + + /** + * Create or update external service account + */ + async upsertAccount(userId, email, password, savePassword, userPassword, service = 'mytischtennis') { + // Verify user's app password + const user = await User.findByPk(userId); + if (!user) { + throw new HttpError(404, 'Benutzer nicht gefunden'); + } + + let loginResult = null; + + // Wenn ein Passwort gesetzt/geändert wird, App-Passwort verifizieren + if (password) { + const isValidPassword = await user.validatePassword(userPassword); + if (!isValidPassword) { + throw new HttpError(401, 'Ungültiges Passwort'); + } + + // Login-Versuch beim entsprechenden Service + const client = this.getClientForService(service); + loginResult = await client.login(email, password); + if (!loginResult.success) { + throw new HttpError(401, loginResult.error || `${service}-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.`); + } + } + + // Find or create account + let account = await ExternalServiceAccount.findOne({ where: { userId, service } }); + + const now = new Date(); + + if (account) { + // Update existing + account.email = email; + account.savePassword = savePassword; + + if (password && savePassword) { + account.setPassword(password); + } else if (!savePassword) { + account.encryptedPassword = null; + } + + if (loginResult && loginResult.success) { + account.lastLoginAttempt = now; + account.lastLoginSuccess = now; + account.accessToken = loginResult.accessToken; + account.refreshToken = loginResult.refreshToken; + account.expiresAt = loginResult.expiresAt; + account.cookie = loginResult.cookie; + account.userData = loginResult.user; + + // Hole Club-ID und Federation (nur für myTischtennis) + if (service === 'mytischtennis') { + console.log('[externalServiceService] - Getting user profile...'); + const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); + console.log('[externalServiceService] - Profile result:', { + success: profileResult.success, + clubId: profileResult.clubId, + clubName: profileResult.clubName, + fedNickname: profileResult.fedNickname + }); + + if (profileResult.success) { + account.clubId = profileResult.clubId; + account.clubName = profileResult.clubName; + account.fedNickname = profileResult.fedNickname; + console.log('[externalServiceService] - Updated account with club data'); + } else { + console.error('[externalServiceService] - Failed to get profile:', profileResult.error); + } + } + } else if (password) { + account.lastLoginAttempt = now; + } + + console.log('[externalServiceService] - Speichere Account (update).'); + try { + await account.save(); + } catch (e) { + console.error('[externalServiceService] - Fehler beim Speichern (update):', e.message, e.parent?.sqlMessage); + throw e; + } + } else { + // Create new + const accountData = { + userId, + service, + email, + savePassword, + lastLoginAttempt: password ? now : null, + lastLoginSuccess: loginResult?.success ? now : null + }; + + if (loginResult && loginResult.success) { + accountData.accessToken = loginResult.accessToken; + accountData.refreshToken = loginResult.refreshToken; + accountData.expiresAt = loginResult.expiresAt; + accountData.cookie = loginResult.cookie; + accountData.userData = loginResult.user; + + // Hole Club-/Verbandsdaten nur für myTischtennis + if (service === 'mytischtennis') { + const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); + if (profileResult.success) { + accountData.clubId = profileResult.clubId; + accountData.clubName = profileResult.clubName; + accountData.fedNickname = profileResult.fedNickname; + } + } + } + + console.log('[externalServiceService] - Erstelle Account (create).'); + try { + account = await ExternalServiceAccount.create(accountData); + } catch (e) { + console.error('[externalServiceService] - Fehler beim Erstellen (create):', e.message, e.parent?.sqlMessage); + throw e; + } + + if (password && savePassword) { + account.setPassword(password); + console.log('[externalServiceService] - Speichere Passwort (nach create).'); + try { + await account.save(); + } catch (e) { + console.error('[externalServiceService] - Fehler beim Speichern (nach create):', e.message, e.parent?.sqlMessage); + throw e; + } + } + } + + return { + id: account.id, + email: account.email, + savePassword: account.savePassword, + lastLoginAttempt: account.lastLoginAttempt, + lastLoginSuccess: account.lastLoginSuccess, + expiresAt: account.expiresAt + }; + } + + /** + * Delete external service account + */ + async deleteAccount(userId, service = 'mytischtennis') { + const deleted = await ExternalServiceAccount.destroy({ + where: { userId, service } + }); + return deleted > 0; + } + + /** + * Verify login with stored or provided credentials + */ + async verifyLogin(userId, providedPassword = null, service = 'mytischtennis') { + const account = await ExternalServiceAccount.findOne({ where: { userId, service } }); + + if (!account) { + throw new HttpError(404, `Kein ${service}-Account verknüpft`); + } + + let password = providedPassword; + + // Wenn kein Passwort übergeben wurde, versuche gespeichertes Passwort zu verwenden + if (!password) { + if (!account.savePassword || !account.encryptedPassword) { + throw new HttpError(400, 'Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.'); + } + password = account.getPassword(); + } + + // Login-Versuch + const now = new Date(); + account.lastLoginAttempt = now; + const client = this.getClientForService(service); + const loginResult = await client.login(account.email, password); + + if (loginResult.success) { + account.lastLoginSuccess = now; + account.accessToken = loginResult.accessToken; + account.refreshToken = loginResult.refreshToken; + account.expiresAt = loginResult.expiresAt; + account.cookie = loginResult.cookie; + account.userData = loginResult.user; + + // Hole Club-/Verbandsdaten nur für myTischtennis + if (service === 'mytischtennis') { + console.log('[externalServiceService] - Getting myTischtennis user profile...'); + const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); + console.log('[externalServiceService] - Profile result:', { + success: profileResult.success, + clubId: profileResult.clubId, + clubName: profileResult.clubName, + fedNickname: profileResult.fedNickname + }); + + if (profileResult.success) { + account.clubId = profileResult.clubId; + account.clubName = profileResult.clubName; + account.fedNickname = profileResult.fedNickname; + console.log('[externalServiceService] - Updated account with club data'); + } else { + console.error('[externalServiceService] - Failed to get profile:', profileResult.error); + } + } + + await account.save(); + + return { + success: true, + accessToken: loginResult.accessToken, + refreshToken: loginResult.refreshToken, + expiresAt: loginResult.expiresAt, + user: loginResult.user, + clubId: account.clubId, + clubName: account.clubName + }; + } else { + await account.save(); // Save lastLoginAttempt + throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen'); + } + } + + /** + * Check if account is configured and ready + */ + async checkAccountStatus(userId, service = 'mytischtennis') { + const account = await ExternalServiceAccount.findOne({ where: { userId, service } }); + + return { + exists: !!account, + hasEmail: !!account?.email, + hasPassword: !!(account?.savePassword && account?.encryptedPassword), + hasValidSession: !!account?.accessToken && account?.expiresAt > Date.now() / 1000, + needsConfiguration: !account || !account.email, + needsPassword: !!account && (!account.savePassword || !account.encryptedPassword) + }; + } + + /** + * Get stored session for user (for authenticated API requests) + */ + async getSession(userId, service = 'mytischtennis') { + const account = await ExternalServiceAccount.findOne({ where: { userId, service } }); + + if (!account) { + throw new HttpError(404, `Kein ${service}-Account verknüpft`); + } + + // Check if session is valid + if (service === 'hettv') { + // HeTTV nutzt Cookie-basierte Session, kein expiresAt verfügbar + if (!account.cookie) { + throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.'); + } + } else { + if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) { + throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.'); + } + } + + return { + accessToken: account.accessToken, + refreshToken: account.refreshToken, + cookie: account.cookie, + expiresAt: account.expiresAt, + userData: account.userData + }; + } + + /** + * Load HeTTV main page and find download links + */ + async loadHettvMainPage(userId) { + const client = this.getClientForService('hettv'); + let session = await this.getSession(userId, 'hettv'); + + const finalUrl = session.userData?.finalUrl || null; + let result = await client.getMainPageWithDownloads(session.cookie, finalUrl); + + // Wenn Session abgelaufen: versuche automatischen Re-Login mit gespeichertem Passwort + if (!result.success && result.status === 401) { + const account = await ExternalServiceAccount.findOne({ where: { userId, service: 'hettv' } }); + if (account && account.savePassword && account.encryptedPassword) { + const password = account.getPassword(); + const login = await client.login(account.email, password); + if (login.success) { + // Session aktualisieren + account.cookie = login.cookie; + account.userData = login.user; + await account.save(); + // Erneut versuchen + result = await client.getMainPageWithDownloads(login.cookie, login.user?.finalUrl || null); + } + } + } + + return result; + } + + /** + * Load specific HeTTV download page + */ + async loadHettvDownloadPage(userId, downloadUrl) { + const session = await this.getSession(userId, 'hettv'); + const client = this.getClientForService('hettv'); + + const finalUrl = session.userData?.finalUrl || null; + return await client.loadDownloadPage(downloadUrl, session.cookie, finalUrl); + } +} + +export default new ExternalServiceService(); + diff --git a/backend/services/memberService.js b/backend/services/memberService.js index cf2de6c..9a8d18e 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -159,20 +159,20 @@ class MemberService { const user = await getUserByToken(userToken); devLog('[updateRatingsFromMyTischtennis] - User:', user.id); - const myTischtennisService = (await import('./myTischtennisService.js')).default; + const externalServiceService = (await import('./externalServiceService.js')).default; const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default; try { // 1. myTischtennis-Session abrufen devLog('[updateRatingsFromMyTischtennis] - Get session for user', user.id); - const session = await myTischtennisService.getSession(user.id); + const session = await externalServiceService.getSession(user.id, 'mytischtennis'); devLog('[updateRatingsFromMyTischtennis] - Session retrieved:', { hasAccessToken: !!session.accessToken, hasCookie: !!session.cookie, expiresAt: session.expiresAt }); - const account = await myTischtennisService.getAccount(user.id); + const account = await externalServiceService.getAccount(user.id, 'mytischtennis'); devLog('[updateRatingsFromMyTischtennis] - Account data:', { id: account?.id, email: account?.email, diff --git a/backend/services/myTischtennisService.js b/backend/services/myTischtennisService.js deleted file mode 100644 index 65f6a8b..0000000 --- a/backend/services/myTischtennisService.js +++ /dev/null @@ -1,257 +0,0 @@ -import MyTischtennis from '../models/MyTischtennis.js'; -import User from '../models/User.js'; -import myTischtennisClient from '../clients/myTischtennisClient.js'; -import HttpError from '../exceptions/HttpError.js'; - -import { devLog } from '../utils/logger.js'; -class MyTischtennisService { - /** - * Get myTischtennis account for user - */ - async getAccount(userId) { - const account = await MyTischtennis.findOne({ - where: { userId }, - attributes: ['id', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt'] - }); - return account; - } - - /** - * Create or update myTischtennis account - */ - async upsertAccount(userId, email, password, savePassword, userPassword) { - // Verify user's app password - const user = await User.findByPk(userId); - if (!user) { - throw new HttpError(404, 'Benutzer nicht gefunden'); - } - - let loginResult = null; - - // Wenn ein Passwort gesetzt/geändert wird, App-Passwort verifizieren - if (password) { - const isValidPassword = await user.validatePassword(userPassword); - if (!isValidPassword) { - throw new HttpError(401, 'Ungültiges Passwort'); - } - - // Login-Versuch bei myTischtennis - loginResult = await myTischtennisClient.login(email, password); - if (!loginResult.success) { - throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.'); - } - } - - // Find or create account - let account = await MyTischtennis.findOne({ where: { userId } }); - - const now = new Date(); - - if (account) { - // Update existing - account.email = email; - account.savePassword = savePassword; - - if (password && savePassword) { - account.setPassword(password); - } else if (!savePassword) { - account.encryptedPassword = null; - } - - if (loginResult && loginResult.success) { - account.lastLoginAttempt = now; - account.lastLoginSuccess = now; - account.accessToken = loginResult.accessToken; - account.refreshToken = loginResult.refreshToken; - account.expiresAt = loginResult.expiresAt; - account.cookie = loginResult.cookie; - account.userData = loginResult.user; - - // Hole Club-ID und Federation - devLog('[myTischtennisService] - Getting user profile...'); - const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); - devLog('[myTischtennisService] - Profile result:', { - success: profileResult.success, - clubId: profileResult.clubId, - clubName: profileResult.clubName, - fedNickname: profileResult.fedNickname - }); - - if (profileResult.success) { - account.clubId = profileResult.clubId; - account.clubName = profileResult.clubName; - account.fedNickname = profileResult.fedNickname; - devLog('[myTischtennisService] - Updated account with club data'); - } else { - console.error('[myTischtennisService] - Failed to get profile:', profileResult.error); - } - } else if (password) { - account.lastLoginAttempt = now; - } - - await account.save(); - } else { - // Create new - const accountData = { - userId, - email, - savePassword, - lastLoginAttempt: password ? now : null, - lastLoginSuccess: loginResult?.success ? now : null - }; - - if (loginResult && loginResult.success) { - accountData.accessToken = loginResult.accessToken; - accountData.refreshToken = loginResult.refreshToken; - accountData.expiresAt = loginResult.expiresAt; - accountData.cookie = loginResult.cookie; - accountData.userData = loginResult.user; - - // Hole Club-ID - const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); - if (profileResult.success) { - accountData.clubId = profileResult.clubId; - accountData.clubName = profileResult.clubName; - } - } - - account = await MyTischtennis.create(accountData); - - if (password && savePassword) { - account.setPassword(password); - await account.save(); - } - } - - return { - id: account.id, - email: account.email, - savePassword: account.savePassword, - lastLoginAttempt: account.lastLoginAttempt, - lastLoginSuccess: account.lastLoginSuccess, - expiresAt: account.expiresAt - }; - } - - /** - * Delete myTischtennis account - */ - async deleteAccount(userId) { - const deleted = await MyTischtennis.destroy({ - where: { userId } - }); - return deleted > 0; - } - - /** - * Verify login with stored or provided credentials - */ - async verifyLogin(userId, providedPassword = null) { - const account = await MyTischtennis.findOne({ where: { userId } }); - - if (!account) { - throw new HttpError(404, 'Kein myTischtennis-Account verknüpft'); - } - - let password = providedPassword; - - // Wenn kein Passwort übergeben wurde, versuche gespeichertes Passwort zu verwenden - if (!password) { - if (!account.savePassword || !account.encryptedPassword) { - throw new HttpError(400, 'Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.'); - } - password = account.getPassword(); - } - - // Login-Versuch - const now = new Date(); - account.lastLoginAttempt = now; - const loginResult = await myTischtennisClient.login(account.email, password); - - if (loginResult.success) { - account.lastLoginSuccess = now; - account.accessToken = loginResult.accessToken; - account.refreshToken = loginResult.refreshToken; - account.expiresAt = loginResult.expiresAt; - account.cookie = loginResult.cookie; - account.userData = loginResult.user; - - // Hole Club-ID und Federation - devLog('[myTischtennisService] - Getting user profile...'); - const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie); - devLog('[myTischtennisService] - Profile result:', { - success: profileResult.success, - clubId: profileResult.clubId, - clubName: profileResult.clubName, - fedNickname: profileResult.fedNickname - }); - - if (profileResult.success) { - account.clubId = profileResult.clubId; - account.clubName = profileResult.clubName; - account.fedNickname = profileResult.fedNickname; - devLog('[myTischtennisService] - Updated account with club data'); - } else { - console.error('[myTischtennisService] - Failed to get profile:', profileResult.error); - } - - await account.save(); - - return { - success: true, - accessToken: loginResult.accessToken, - refreshToken: loginResult.refreshToken, - expiresAt: loginResult.expiresAt, - user: loginResult.user, - clubId: account.clubId, - clubName: account.clubName - }; - } else { - await account.save(); // Save lastLoginAttempt - throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen'); - } - } - - /** - * Check if account is configured and ready - */ - async checkAccountStatus(userId) { - const account = await MyTischtennis.findOne({ where: { userId } }); - - return { - exists: !!account, - hasEmail: !!account?.email, - hasPassword: !!(account?.savePassword && account?.encryptedPassword), - hasValidSession: !!account?.accessToken && account?.expiresAt > Date.now() / 1000, - needsConfiguration: !account || !account.email, - needsPassword: !!account && (!account.savePassword || !account.encryptedPassword) - }; - } - - /** - * Get stored session for user (for authenticated API requests) - */ - async getSession(userId) { - const account = await MyTischtennis.findOne({ where: { userId } }); - - if (!account) { - throw new HttpError(404, 'Kein myTischtennis-Account verknüpft'); - } - - // Check if session is valid - if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) { - throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.'); - } - - return { - accessToken: account.accessToken, - refreshToken: account.refreshToken, - cookie: account.cookie, - expiresAt: account.expiresAt, - userData: account.userData - }; - } -} - -export default new MyTischtennisService(); - diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d175d24..3f7b2c3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -75,6 +75,10 @@ 🔗 myTischtennis-Account + + 🔗 + HeTTV-Account + diff --git a/frontend/src/components/HettvDialog.vue b/frontend/src/components/HettvDialog.vue new file mode 100644 index 0000000..bc5b49b --- /dev/null +++ b/frontend/src/components/HettvDialog.vue @@ -0,0 +1,291 @@ + + + + + + diff --git a/frontend/src/components/MyTischtennisDialog.vue b/frontend/src/components/MyTischtennisDialog.vue index cdd53c7..f5789ac 100644 --- a/frontend/src/components/MyTischtennisDialog.vue +++ b/frontend/src/components/MyTischtennisDialog.vue @@ -121,7 +121,8 @@ export default { try { const payload = { email: this.formData.email, - savePassword: this.formData.savePassword + savePassword: this.formData.savePassword, + service: 'mytischtennis' }; // Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde @@ -130,7 +131,7 @@ export default { payload.userPassword = this.formData.userPassword; } - await apiClient.post('/mytischtennis/account', payload); + await apiClient.post('/external-service/account', payload); this.$emit('saved'); } catch (error) { console.error('Fehler beim Speichern:', error); diff --git a/frontend/src/router.js b/frontend/src/router.js index 13599d0..f8a138e 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -14,6 +14,7 @@ import TrainingStatsView from './views/TrainingStatsView.vue'; import PredefinedActivities from './views/PredefinedActivities.vue'; import OfficialTournaments from './views/OfficialTournaments.vue'; import MyTischtennisAccount from './views/MyTischtennisAccount.vue'; +import HettvAccount from './views/HettvAccount.vue'; import Impressum from './views/Impressum.vue'; import Datenschutz from './views/Datenschutz.vue'; @@ -33,6 +34,7 @@ const routes = [ { path: '/predefined-activities', component: PredefinedActivities }, { path: '/official-tournaments', component: OfficialTournaments }, { path: '/mytischtennis-account', component: MyTischtennisAccount }, + { path: '/hettv-account', component: HettvAccount }, { path: '/impressum', component: Impressum }, { path: '/datenschutz', component: Datenschutz }, ]; diff --git a/frontend/src/views/HettvAccount.vue b/frontend/src/views/HettvAccount.vue new file mode 100644 index 0000000..7260b14 --- /dev/null +++ b/frontend/src/views/HettvAccount.vue @@ -0,0 +1,314 @@ + + + + + + diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 193dbbd..56bf013 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -387,10 +387,6 @@ export default { return ''; }, async updateRatingsFromMyTischtennis() { - if (!confirm('TTR/QTTR-Werte von myTischtennis aktualisieren?')) { - return; - } - this.isUpdatingRatings = true; try { const response = await apiClient.post(`/clubmembers/update-ratings/${this.currentClub}`); diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 7e22d1c..9b7e905 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -1,7 +1,12 @@