Entfernt die myTischtennis-Integration aus dem Backend und Frontend. Löscht Controller, Routen und Service für myTischtennis. Aktualisiert die Datenmodelle, um die neue ExternalServiceAccount-Integration zu unterstützen. Ändert die API-Routen und Frontend-Komponenten, um die neuen Endpunkte zu verwenden.

This commit is contained in:
Torsten Schulz (local)
2025-10-01 21:01:09 +02:00
parent 1ef1711eea
commit 7be98ffeeb
18 changed files with 2008 additions and 441 deletions

View File

@@ -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<Object>} 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(/<input[^>]*value="([^"]+)"[^>]*name="t:formdata"/);
}
if (!formDataMatch) {
// Suche nach hidden input mit t:formdata (name vor value)
formDataMatch = htmlContent.match(/<input[^>]*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(/<input[^>]*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<boolean>} 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<Object>} 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<Object>} 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: <a ...>Downloads</a>
const anchorPattern = /<a[^>]*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 = /<nav[\s\S]*?<\/nav>/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<Object>} 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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,

View File

@@ -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();

View File

@@ -75,6 +75,10 @@
<span class="nav-icon">🔗</span>
myTischtennis-Account
</a>
<a href="/hettv-account" class="nav-link">
<span class="nav-icon">🔗</span>
HeTTV-Account
</a>
</div>
</nav>

View File

@@ -0,0 +1,291 @@
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>{{ account ? 'HeTTV-Account bearbeiten' : 'HeTTV-Account verknüpfen' }}</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label for="hettv-email">HeTTV-E-Mail:</label>
<input
type="email"
id="hettv-email"
v-model="formData.email"
placeholder="Ihre HeTTV-E-Mail-Adresse"
required
/>
</div>
<div class="form-group">
<label for="hettv-password">HeTTV-Passwort:</label>
<input
type="password"
id="hettv-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? 'Leer lassen um beizubehalten' : 'Ihr HeTTV-Passwort'"
/>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.savePassword"
/>
<span>HeTTV-Passwort speichern</span>
</label>
<p class="hint">
Wenn aktiviert, wird Ihr HeTTV-Passwort verschlüsselt gespeichert,
sodass automatische Synchronisationen möglich sind.
</p>
</div>
<div class="form-group" v-if="formData.password">
<label for="app-password">Ihr App-Passwort zur Bestätigung:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
placeholder="Ihr Passwort für diese App"
required
/>
<p class="hint">
Aus Sicherheitsgründen benötigen wir Ihr App-Passwort,
um das HeTTV-Passwort zu speichern.
</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
Abbrechen
</button>
<button class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
{{ saving ? 'Speichere...' : 'Speichern' }}
</button>
</div>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
export default {
name: 'HettvDialog',
props: {
account: {
type: Object,
default: null
}
},
data() {
return {
formData: {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
userPassword: ''
},
saving: false,
error: null
};
},
computed: {
canSave() {
if (!this.formData.email.trim()) {
return false;
}
if (this.formData.password && !this.formData.userPassword) {
return false;
}
return true;
}
},
methods: {
async saveAccount() {
if (!this.canSave) return;
this.error = null;
this.saving = true;
try {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword,
service: 'hettv'
};
if (this.formData.password) {
payload.password = this.formData.password;
payload.userPassword = this.formData.userPassword;
}
await apiClient.post('/external-service/account', payload);
this.$emit('saved');
} catch (error) {
console.error('Fehler beim Speichern:', error);
this.error = error.response?.data?.message || 'Fehler beim Speichern des Accounts';
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
color: #495057;
}
.modal-body {
padding: 1.5rem;
flex: 1;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input[type="text"]:focus,
.form-group input[type="email"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
}
.hint {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
}
.error-message {
padding: 0.75rem;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
color: #721c24;
margin-top: 1rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #545b62;
}
.btn-secondary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -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);

View File

@@ -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 },
];

View File

@@ -0,0 +1,314 @@
<template>
<div class="page-container">
<h1>HeTTV-Account</h1>
<div class="account-container">
<div v-if="loading" class="loading">Lade...</div>
<div v-else-if="account" class="account-info">
<div class="info-section">
<h2>Verknüpfter Account</h2>
<div class="info-row">
<label>E-Mail:</label>
<span>{{ account.email }}</span>
</div>
<div class="info-row">
<label>Passwort gespeichert:</label>
<span>{{ account.savePassword ? 'Ja' : 'Nein' }}</span>
</div>
<div class="info-row" v-if="account.lastLoginSuccess">
<label>Letzter erfolgreicher Login:</label>
<span>{{ formatDate(account.lastLoginSuccess) }}</span>
</div>
<div class="info-row" v-if="account.lastLoginAttempt">
<label>Letzter Login-Versuch:</label>
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
</div>
<div class="button-group">
<button class="btn-primary" @click="openEditDialog">Account bearbeiten</button>
<button class="btn-secondary" @click="testConnection">Erneut einloggen</button>
<button class="btn-danger" @click="deleteAccount">Account trennen</button>
</div>
</div>
</div>
<div v-else class="no-account">
<p>Kein HeTTV-Account verknüpft.</p>
<button class="btn-primary" @click="openEditDialog">Account verknüpfen</button>
</div>
<div class="info-box">
<h3>Über HeTTV</h3>
<p>Durch die Verknüpfung Ihres HeTTV-Accounts können Sie:</p>
<ul>
<li>Spielerdaten automatisch synchronisieren</li>
<li>Ligainformationen abrufen</li>
<li>Wettkampfdaten direkt importieren</li>
</ul>
<p><strong>Hinweis:</strong> Das Speichern des Passworts ist optional. Wenn Sie es nicht speichern, werden Sie bei jeder Synchronisation nach dem Passwort gefragt.</p>
</div>
</div>
<!-- Edit Dialog -->
<HettvDialog
v-if="showDialog"
:account="account"
@close="closeDialog"
@saved="onAccountSaved"
/>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import HettvDialog from '../components/HettvDialog.vue';
export default {
name: 'HettvAccount',
components: {
HettvDialog
},
data() {
return {
loading: true,
account: null,
showDialog: false
};
},
mounted() {
this.loadAccount();
},
methods: {
async loadAccount() {
try {
this.loading = true;
const response = await apiClient.get('/external-service/account', {
params: { service: 'hettv' }
});
this.account = response.data.account;
} catch (error) {
console.error('Fehler beim Laden des Accounts:', error);
this.$store.dispatch('showMessage', {
text: 'Fehler beim Laden des HeTTV-Accounts',
type: 'error'
});
} finally {
this.loading = false;
}
},
openEditDialog() {
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
},
async onAccountSaved() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: 'HeTTV-Account erfolgreich gespeichert',
type: 'success'
});
},
async testConnection() {
try {
await apiClient.post('/external-service/verify', {
service: 'hettv'
});
this.$store.dispatch('showMessage', {
text: 'Login erfolgreich! Verbindungsdaten aktualisiert.',
type: 'success'
});
await this.loadAccount();
} catch (error) {
const message = error.response?.data?.message || 'Login fehlgeschlagen';
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
this.showDialog = true;
}
this.$store.dispatch('showMessage', {
text: message,
type: 'error'
});
}
},
async deleteAccount() {
if (!confirm('Möchten Sie die Verknüpfung zum HeTTV-Account wirklich trennen?')) {
return;
}
try {
await apiClient.delete('/external-service/account', {
params: { service: 'hettv' }
});
this.account = null;
this.$store.dispatch('showMessage', {
text: 'HeTTV-Account erfolgreich getrennt',
type: 'success'
});
} catch (error) {
console.error('Fehler beim Löschen des Accounts:', error);
this.$store.dispatch('showMessage', {
text: 'Fehler beim Trennen des Accounts',
type: 'error'
});
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
};
</script>
<style scoped>
.page-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: var(--text-color, #333);
margin-bottom: 2rem;
}
.account-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.account-info, .no-account {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.info-section h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: var(--primary-color, #007bff);
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.info-row:last-of-type {
border-bottom: none;
}
.info-row label {
font-weight: 600;
color: #555;
}
.info-row span {
color: #333;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.no-account {
text-align: center;
}
.no-account p {
margin-bottom: 1.5rem;
color: #666;
}
.info-box {
background: #f8f9fa;
border-left: 4px solid var(--primary-color, #007bff);
padding: 1.5rem;
border-radius: 4px;
}
.info-box h3 {
margin-top: 0;
color: var(--primary-color, #007bff);
}
.info-box ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.info-box li {
margin: 0.5rem 0;
}
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>

View File

@@ -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}`);

View File

@@ -1,7 +1,12 @@
<template>
<div>
<h2>Spielpläne</h2>
<button @click="openImportModal">Spielplanimport</button>
<div class="button-group">
<button @click="openImportModal">Spielplanimport</button>
<button @click="loadHettvData" :disabled="loadingHettv" class="hettv-button">
{{ loadingHettv ? 'Lade HeTTV-Daten...' : 'HeTTV-Daten laden' }}
</button>
</div>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
@@ -79,6 +84,8 @@ export default {
matches: [],
selectedLeague: '',
hoveredMatch: null,
loadingHettv: false,
hettvData: null,
};
},
methods: {
@@ -174,10 +181,15 @@ export default {
async loadLeagues() {
try {
const clubId = this.currentClub;
if (!clubId || clubId === 'null') {
console.log('Kein Club ausgewählt, überspringe Liga-Laden');
return;
}
const response = await apiClient.get(`/matches/leagues/current/${clubId}`);
this.leagues = this.sortLeagues(response.data);
} catch (error) {
alert('Fehler beim Laden der Ligen');
console.log('Fehler beim Laden der Ligen:', error.message);
// Keine Alert mehr, da das normal ist wenn kein Club-Zugriff vorhanden
}
},
async loadMatchesForLeague(leagueId, leagueName) {
@@ -301,6 +313,39 @@ export default {
return ''; // Keine besondere Farbe
},
async loadHettvData() {
this.loadingHettv = true;
try {
console.log('Lade HeTTV-Hauptseite...');
const response = await apiClient.get('/external-service/hettv/main-page');
if (response.data.success) {
this.hettvData = response.data.data;
console.log('HeTTV-Daten geladen:', {
downloadLinks: this.hettvData.downloadLinks,
htmlLength: this.hettvData.htmlContent.length,
trace: this.hettvData.trace,
lastUrl: this.hettvData.lastUrl,
lastStatus: this.hettvData.lastStatus,
savedFile: this.hettvData.savedFile
});
// Zeige gefundene Download-Links
if (this.hettvData.downloadLinks.length > 0) {
alert(`HeTTV-Daten erfolgreich geladen!\n\nGefundene Download-Links:\n${this.hettvData.downloadLinks.join('\n')}`);
} else {
alert('HeTTV-Hauptseite geladen, aber keine Download-Links gefunden.');
}
} else {
alert('Fehler beim Laden der HeTTV-Daten: ' + response.data.error);
}
} catch (error) {
console.error('HeTTV-Fehler:', error);
alert('Fehler beim Laden der HeTTV-Daten: ' + (error.response?.data?.error || error.message));
} finally {
this.loadingHettv = false;
}
},
},
async created() {
await this.loadLeagues();
@@ -451,4 +496,37 @@ li {
.match-next-week:hover {
background-color: #b8daff !important; /* Dunkleres Blau beim Hover */
}
.button-group {
margin-bottom: 20px;
}
.button-group button {
margin-right: 10px;
padding: 8px 16px;
border: 1px solid #ddd;
background-color: #f8f9fa;
cursor: pointer;
border-radius: 4px;
}
.button-group button:hover {
background-color: #e9ecef;
}
.hettv-button {
background-color: #007bff !important;
color: white !important;
border-color: #007bff !important;
}
.hettv-button:hover {
background-color: #0056b3 !important;
}
.hettv-button:disabled {
background-color: #6c757d !important;
border-color: #6c757d !important;
cursor: not-allowed;
}
</style>