From 8f6fda2e61117e2d30d7f415724a39202e3eefe4 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 4 Sep 2026 12:45:22 +0200 Subject: [PATCH 01/13] =?UTF-8?q?F=C3=BCge=20myTischtennis-Verbindung=20hi?= =?UTF-8?q?nzu:=20Implementiere=20Authentifizierung,=20Import=20von=20TTR-?= =?UTF-8?q?Werten=20und=20speichere=20Verbindungsdaten=20sicher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 49 ++++++++++++++- package.json | 1 + pages/cms/index.vue | 16 ++++- pages/cms/mytischtennis.vue | 45 ++++++++++++++ server/api/cms/mytischtennis.get.js | 10 +++ server/api/cms/mytischtennis.put.js | 17 +++++ server/api/cms/mytischtennis/import.post.js | 19 ++++++ server/utils/mytischtennis-client.js | 69 +++++++++++++++++++++ server/utils/mytischtennis-connection.js | 44 +++++++++++++ server/utils/qttr-import.js | 35 ++++++++++- 10 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 pages/cms/mytischtennis.vue create mode 100644 server/api/cms/mytischtennis.get.js create mode 100644 server/api/cms/mytischtennis.put.js create mode 100644 server/api/cms/mytischtennis/import.post.js create mode 100644 server/utils/mytischtennis-client.js create mode 100644 server/utils/mytischtennis-connection.js 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 } +} -- 2.34.1 From 9ca146489973606be3ed9896bb85515f44c6532a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 4 Sep 2026 13:06:32 +0200 Subject: [PATCH 02/13] Aktualisiere qs auf Version 6.16.0 und passe den OSV-Scanner-Pfad an --- .gitea/workflows/code-analysis.yml | 5 +++-- package-lock.json | 4 +++- package.json | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/code-analysis.yml b/.gitea/workflows/code-analysis.yml index 12fb932..80605a9 100755 --- a/.gitea/workflows/code-analysis.yml +++ b/.gitea/workflows/code-analysis.yml @@ -121,11 +121,12 @@ jobs: - name: OSV-Scanner (SCA) run: | + cd "$GITHUB_WORKSPACE" curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64 chmod +x osv-scanner ./osv-scanner --version - test -f ./package-lock.json - ./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml + test -f "$GITHUB_WORKSPACE/package-lock.json" + ./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" deploy-production: runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index b6837a4..18c1cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9270,7 +9270,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 86f6d7e..84e49ab 100755 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ }, "overrides": { "@peculiar/x509": "1.13.0", - "esbuild": "0.28.1" + "esbuild": "0.28.1", + "qs": "6.16.0" } } -- 2.34.1 From 497d804244041c541907f2e9aed4f12044529e48 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 4 Sep 2026 13:53:39 +0200 Subject: [PATCH 03/13] =?UTF-8?q?Verbessere=20das=20Layout=20des=20Navigat?= =?UTF-8?q?ionsmen=C3=BCs:=20F=C3=BCge=20Flexbox-Klassen=20f=C3=BCr=20das?= =?UTF-8?q?=20'intern'-Submen=C3=BC=20hinzu=20und=20optimiere=20die=20Dars?= =?UTF-8?q?tellung=20der=20Trennlinien.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/Navigation.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/Navigation.vue b/components/Navigation.vue index 97f5462..6c23640 100755 --- a/components/Navigation.vue +++ b/components/Navigation.vue @@ -117,7 +117,7 @@