diff --git a/package-lock.json b/package-lock.json index bfcc4ef..b6837a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "harheimertc-website", - "version": "1.8.7", + "version": "1.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "harheimertc-website", - "version": "1.8.7", + "version": "1.8.8", "hasInstallScript": true, "dependencies": { "@pinia/nuxt": "^0.11.2", @@ -21,6 +21,7 @@ "pdf-lib": "^1.17.1", "pdf-parse": "^2.4.5", "pinia": "^3.0.3", + "playwright": "^1.62.1", "quill": "2.0.2", "sharp": "^0.35.3", "vue": "^3.5.22" @@ -8527,6 +8528,50 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "dev": true, diff --git a/package.json b/package.json index 78107f9..86f6d7e 100755 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "pdf-lib": "^1.17.1", "pdf-parse": "^2.4.5", "pinia": "^3.0.3", + "playwright": "^1.62.1", "quill": "2.0.2", "sharp": "^0.35.3", "vue": "^3.5.22" diff --git a/pages/cms/index.vue b/pages/cms/index.vue index 16d98a8..e036988 100755 --- a/pages/cms/index.vue +++ b/pages/cms/index.vue @@ -201,6 +201,20 @@

+ +
+
+ +
+

myTischtennis

+
+

Vereinszugang und TTR-Synchronisierung

+
+ diff --git a/server/api/cms/mytischtennis.get.js b/server/api/cms/mytischtennis.get.js new file mode 100644 index 0000000..511c4e9 --- /dev/null +++ b/server/api/cms/mytischtennis.get.js @@ -0,0 +1,10 @@ +import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' +import { publicConnectionStatus, readMyTischtennisConnection } from '../../utils/mytischtennis-connection.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const user = token ? await getUserFromToken(token) : null + if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' }) + return publicConnectionStatus(await readMyTischtennisConnection()) +}) diff --git a/server/api/cms/mytischtennis.put.js b/server/api/cms/mytischtennis.put.js new file mode 100644 index 0000000..6d6cb00 --- /dev/null +++ b/server/api/cms/mytischtennis.put.js @@ -0,0 +1,17 @@ +import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' +import { readMyTischtennisConnection, saveMyTischtennisConnection, publicConnectionStatus } from '../../utils/mytischtennis-connection.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const user = token ? await getUserFromToken(token) : null + if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' }) + const body = await readBody(event) + if (!/^\S+@\S+\.\S+$/.test(String(body.email || '')) || String(body.password || '').length < 1) { + throw createError({ statusCode: 400, statusMessage: 'E-Mail und Passwort sind erforderlich.' }) + } + const previous = await readMyTischtennisConnection() + const connection = { ...previous, email: body.email.trim(), password: body.password, association: String(body.association || 'HeTTV'), clubId: String(body.clubId || '43030') } + await saveMyTischtennisConnection(connection) + return publicConnectionStatus(connection) +}) diff --git a/server/api/cms/mytischtennis/import.post.js b/server/api/cms/mytischtennis/import.post.js new file mode 100644 index 0000000..51cad21 --- /dev/null +++ b/server/api/cms/mytischtennis/import.post.js @@ -0,0 +1,19 @@ +import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js' +import { importQttrValues } from '../../../utils/qttr-import.js' +import { readMyTischtennisConnection, saveMyTischtennisConnection } from '../../../utils/mytischtennis-connection.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const user = token ? await getUserFromToken(token) : null + if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' }) + const connection = await readMyTischtennisConnection() + if (!connection) throw createError({ statusCode: 409, statusMessage: 'Keine myTischtennis-Verbindung eingerichtet.' }) + try { + const result = await importQttrValues({ connection }) + return { success: true, rowCount: result.rowCount, importedAt: result.importedAt } + } catch (error) { + await saveMyTischtennisConnection({ ...connection, lastImportError: String(error.message || error).slice(0, 500) }) + throw createError({ statusCode: 502, statusMessage: 'TTR-Abruf bei myTischtennis fehlgeschlagen.' }) + } +}) diff --git a/server/utils/mytischtennis-client.js b/server/utils/mytischtennis-client.js new file mode 100644 index 0000000..921c394 --- /dev/null +++ b/server/utils/mytischtennis-client.js @@ -0,0 +1,69 @@ +import { chromium } from 'playwright' + +const BASE_URL = 'https://www.mytischtennis.de' +const HEADERS = { 'accept-language': 'de-DE,de;q=0.9', 'user-agent': 'Harheimer-TC-Vereinsverwaltung/1.0' } + +function findAuthCookie(headers) { + const raw = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []) + return raw.find(value => value.startsWith('sb-10-auth-token='))?.split(';')[0] || null +} + +function findEntries(value) { + if (!value || typeof value !== 'object') return null + if (Array.isArray(value.entries)) return value.entries + for (const child of Object.values(value)) { + const result = findEntries(child) + if (result) return result + } + return null +} + +async function loginDirect(email, password) { + const page = await fetch(`${BASE_URL}/login?next=%2F`, { headers: HEADERS }) + const html = await page.text() + if (/captcha/i.test(html)) throw new Error('CAPTCHA_REQUIRED') + const xsrf = html.match(/name=["']xsrf["'][^>]*value=["']([^"']+)/i)?.[1] + const form = new URLSearchParams({ email, password, intent: 'login' }) + if (xsrf) form.set('xsrf', xsrf) + const result = await fetch(`${BASE_URL}/login?next=%2F&_data=routes%2F_auth%2B%2Flogin`, { + method: 'POST', headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded' }, body: form, redirect: 'manual' + }) + const cookie = findAuthCookie(result.headers) + if (!cookie) throw new Error('Direkter myTischtennis-Login fehlgeschlagen.') + return cookie +} + +async function loginWithBrowser(email, password) { + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'networkidle' }) + await page.locator('input[name="email"]').fill(email) + await page.locator('input[name="password"]').fill(password) + // Das CAPTCHA wird vom Anbieter im Browser erzeugt. Ohne erfolgreiches Token wird nicht abgesendet. + await page.waitForFunction(() => { + const input = document.querySelector('input[name="captcha"]') + return Boolean(input?.value) + }, { timeout: 25_000 }) + await page.locator('form').evaluate(form => form.requestSubmit()) + await page.waitForTimeout(1_500) + const cookie = (await page.context().cookies(BASE_URL)).find(item => item.name === 'sb-10-auth-token') + if (!cookie) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.') + return `${cookie.name}=${cookie.value}` + } finally { await browser.close() } +} + +export async function fetchClubRankings(connection) { + let cookie + try { cookie = await loginDirect(connection.email, connection.password) } catch (error) { + if (error.message !== 'CAPTCHA_REQUIRED') throw error + cookie = await loginWithBrowser(connection.email, connection.password) + } + const url = new URL('/rankings/andro-rangliste', BASE_URL) + url.search = new URLSearchParams({ 'all-players': 'on', clubnr: connection.clubId, fednickname: connection.association, 'current-ranking': 'yes', 'results-per-page': '100', page: '0', _data: 'routes/$' }) + const response = await fetch(url, { headers: { ...HEADERS, cookie, accept: 'application/json', referer: `${BASE_URL}/` } }) + if (!response.ok) throw new Error(`Ranglisten-Abruf fehlgeschlagen (HTTP ${response.status}).`) + const entries = findEntries(await response.json()) + if (!entries) throw new Error('myTischtennis hat keine Ranglisten-Daten geliefert.') + return entries +} diff --git a/server/utils/mytischtennis-connection.js b/server/utils/mytischtennis-connection.js new file mode 100644 index 0000000..0be49c0 --- /dev/null +++ b/server/utils/mytischtennis-connection.js @@ -0,0 +1,44 @@ +import { promises as fs } from 'fs' +import { encryptObject, decryptObject } from './encryption.js' +import { getServerDataPath } from './paths.js' + +const FILE = getServerDataPath('mytischtennis-connection.json') + +function encryptionKey() { + const key = process.env.ENCRYPTION_KEY + if (!key) throw new Error('ENCRYPTION_KEY ist für die myTischtennis-Verbindung erforderlich.') + return key +} + +export async function readMyTischtennisConnection() { + try { + const stored = JSON.parse(await fs.readFile(FILE, 'utf8')) + return decryptObject(stored.encrypted, encryptionKey()) + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } +} + +export async function saveMyTischtennisConnection(connection) { + const payload = { + version: 1, + encrypted: encryptObject(connection, encryptionKey()) + } + await fs.mkdir(getServerDataPath(), { recursive: true }) + await fs.writeFile(FILE, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 }) +} + +export function publicConnectionStatus(connection) { + if (!connection) return { configured: false } + const email = String(connection.email || '') + const [name, domain] = email.split('@') + return { + configured: true, + email: name ? `${name.slice(0, 2)}${'•'.repeat(Math.max(1, name.length - 2))}${domain ? `@${domain}` : ''}` : null, + association: connection.association || null, + clubId: connection.clubId || null, + lastSuccessfulImportAt: connection.lastSuccessfulImportAt || null, + lastImportError: connection.lastImportError || null + } +} diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js index 5c5428d..a565edd 100755 --- a/server/utils/qttr-import.js +++ b/server/utils/qttr-import.js @@ -1,5 +1,7 @@ import { promises as fs } from 'fs' import { getServerDataPath } from './paths.js' +import { fetchClubRankings } from './mytischtennis-client.js' +import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js' const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021' const OUTPUT_FILE = getServerDataPath('qttr-values.json') @@ -160,6 +162,9 @@ function deriveQttrFields(headers, cells) { } export async function importQttrValues(options = {}) { + const connection = options.connection ?? await readMyTischtennisConnection() + if (connection) return importAuthenticatedTtrValues(connection) + const url = options.url || QTTR_URL const response = await fetch(url, { headers: { @@ -207,4 +212,32 @@ export async function importQttrValues(options = {}) { rowCount: parsedRows.length, ...payload } -} \ No newline at end of file +} + +async function importAuthenticatedTtrValues(connection) { + const entries = await fetchClubRankings(connection) + const rows = entries.map((entry, index) => ({ + rank: toNumberOrNull(entry.rank ?? entry.ranking ?? index + 1), + playerNumber: toNumberOrNull(entry.playernr ?? entry.player_number ?? entry.nuid), + gender: normalizeGender(entry.gender), + playerName: `${entry.firstname ?? entry.first_name ?? ''} ${entry.lastname ?? entry.last_name ?? ''}`.trim() || entry.name || null, + clubName: entry.clubname ?? entry.club_name ?? 'Harheimer TC', + currentQttr: toNumberOrNull(entry.qttr ?? entry.q_ttr ?? entry.ttr), + currentTtr: toNumberOrNull(entry.ttr ?? entry.current_ttr), + previousQttr: null, + valuesByHeader: entry, + rawCells: [] + })).filter(row => row.playerName && row.currentQttr != null) + + if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.') + const importedAt = new Date().toISOString() + const payload = { + format: 'harheimertc.qttr.v2', importedAt, + source: { type: 'mytischtennis-authenticated', association: connection.association, clubId: connection.clubId }, + title: 'Aktuelle myTischtennis-Rangliste', headerCount: 0, rowCount: rows.length, headers: [], rows + } + await fs.mkdir(getServerDataPath(), { recursive: true }) + await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null }) + return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload } +}