commit cf188c172770984aafbd1a14d1b0341188f88b8d Author: xziino Date: Wed May 20 08:05:09 2026 +0200 Initial commit: FFLogs PKCE OAuth viewer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd5639a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.claude/ +debug/ +fflogs-schema.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..262dd82 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# ff14-auth — FFLogs Report Viewer + +## Projekt +Einfache PHP/HTML/JS-Seite zum Verbinden mit FFLogs via OAuth2 PKCE und Abrufen von Report-Daten über die GraphQL-API. Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting. + +## Dateistruktur +``` +config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe() +index.php — Haupt-UI: Connect-Button / Report-Formular / Terminal-Output +auth/ + start.php — PKCE generieren, Session speichern, Redirect zu FFLogs + callback.php — Code gegen Token tauschen, Token in Session speichern +api/ + fight.php — POST-Endpunkt: GraphQL-Query → JSON +js/ + app.js — Formular, Dropdown, Fetch, Ausgabe +debug/ + schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen) +``` + +## Konfiguration +- `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen +- `DEV_MODE = true` deaktiviert SSL-Verifizierung (nur lokal, nie in Produktion) +- `session.cookie_secure` ist bei `DEV_MODE` automatisch `false` + +## FFLogs API +- OAuth2 PKCE (kein Client Secret, öffentliche App) +- App registrieren: https://www.fflogs.com/api/clients/ +- GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user` +- Token Endpoint: `https://www.fflogs.com/oauth/token` +- Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu + +## Lokale Entwicklung +``` +php -S localhost:8080 +``` +Dann `http://localhost:8080` im Browser öffnen. +Redirect URI in FFLogs App und config.php: `http://localhost:8080/auth/callback.php` + +## Bekannte Schema-Infos (ReportFight) +Verfügbare aber noch nicht genutzte Felder: `friendlyPlayers`, `enemyNPCs`, +`lastPhase`, `standardComposition`, `hasEcho`, `combatTime`, `phaseTransitions` +Vollständiges Schema: siehe `debug/schema.php` oder `fflogs-schema.json` + +## Deployment +- `DEV_MODE` auf `false` setzen +- `REDIRECT_URI` auf produktive HTTPS-URL anpassen +- `debug/` Ordner nicht deployen diff --git a/api/fight.php b/api/fight.php new file mode 100644 index 0000000..5fb5590 --- /dev/null +++ b/api/fight.php @@ -0,0 +1,98 @@ + 'Method not allowed']); + exit; +} + +$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? ''); + +if (strlen($reportCode) < 1) { + http_response_code(400); + echo json_encode(['error' => 'Missing or invalid report_code']); + exit; +} + +if (empty($_SESSION['access_token'])) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated', 'reauth' => true]); + exit; +} + +if (($_SESSION['token_expires'] ?? 0) <= time()) { + http_response_code(401); + echo json_encode(['error' => 'Token expired', 'reauth' => true]); + exit; +} + +$query = <<<'GQL' +query GetReportData($reportCode: String!) { + reportData { + report(code: $reportCode) { + title + startTime + endTime + fights { + id + name + startTime + endTime + kill + difficulty + bossPercentage + fightPercentage + averageItemLevel + } + } + } +} +GQL; + +$payload = json_encode([ + 'query' => $query, + 'variables' => ['reportCode' => $reportCode], +]); + +$ch = curl_init(GRAPHQL_URI); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $_SESSION['access_token'], + ], + CURLOPT_SSL_VERIFYPEER => !DEV_MODE, +]); +$body = curl_exec($ch); +$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$curlError = curl_error($ch); + +if ($curlError) { + http_response_code(502); + echo json_encode(['error' => 'Network error: ' . $curlError]); + exit; +} + +$decoded = json_decode($body, true); +if ($decoded === null) { + http_response_code(502); + echo json_encode(['error' => 'Invalid response from FFLogs', 'raw' => substr($body, 0, 500)]); + exit; +} + +if ($httpStatus === 401) { + http_response_code(401); + echo json_encode(['error' => 'FFLogs rejected token', 'reauth' => true]); + exit; +} + +http_response_code($httpStatus === 200 ? 200 : $httpStatus); +echo $body; +exit; diff --git a/auth/callback.php b/auth/callback.php new file mode 100644 index 0000000..795a115 --- /dev/null +++ b/auth/callback.php @@ -0,0 +1,67 @@ +Back'; + exit; +} + +if (empty($_GET['code'])) { + header('Location: ../index.php?error=missing_code'); + exit; +} + +$verifier = $_SESSION['pkce_verifier']; +unset($_SESSION['pkce_verifier'], $_SESSION['oauth_state']); + +$post = http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => CLIENT_ID, + 'redirect_uri' => REDIRECT_URI, + 'code' => $_GET['code'], + 'code_verifier' => $verifier, +]); + +$ch = curl_init(TOKEN_URI); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $post, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], + CURLOPT_SSL_VERIFYPEER => !DEV_MODE, +]); +$body = curl_exec($ch); +$status = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$curlError = curl_error($ch); + +$data = json_decode($body, true); + +if ($curlError || $status !== 200 || empty($data['access_token'])) { + $_SESSION['token_debug'] = [ + 'curl_error' => $curlError ?: null, + 'http_status' => $status, + 'response_body' => $body, + ]; + header('Location: ../index.php?error=token_failed'); + exit; +} + +$_SESSION['access_token'] = $data['access_token']; +$_SESSION['token_expires'] = time() + ($data['expires_in'] ?? 3600); + +header('Location: ../index.php'); +exit; diff --git a/auth/start.php b/auth/start.php new file mode 100644 index 0000000..fa39fda --- /dev/null +++ b/auth/start.php @@ -0,0 +1,22 @@ + 'code', + 'client_id' => CLIENT_ID, + 'redirect_uri' => REDIRECT_URI, + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', +]); + +header('Location: ' . AUTHORIZE_URI . '?' . $params); +exit; diff --git a/config.php b/config.php new file mode 100644 index 0000000..148ba52 --- /dev/null +++ b/config.php @@ -0,0 +1,21 @@ + 0, + 'path' => '/', + 'secure' => false, // set to true in production (HTTPS) + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..338637e --- /dev/null +++ b/index.php @@ -0,0 +1,216 @@ + time(); + +$tokenExpired = !empty($_SESSION['access_token']) + && ($_SESSION['token_expires'] ?? 0) <= time(); + +$error = $_GET['error'] ?? null; +$errorMessages = [ + 'access_denied' => 'Access denied — you declined the FFLogs authorization.', + 'token_failed' => 'Could not retrieve access token from FFLogs. Please try again.', + 'missing_code' => 'Authorization code missing in callback.', +]; +$errorText = $error ? ($errorMessages[$error] ?? 'Unknown error: ' . htmlspecialchars($error)) : null; +$tokenDebug = $_SESSION['token_debug'] ?? null; +unset($_SESSION['token_debug']); +?> + + + + + + FFLogs Report Viewer + + + +
+ + +
+ + +
+ Debug — FFLogs Token Response:
+ HTTP
+
+
+ + + +
+

// FFLogs Report Viewer

+

Connect your FFLogs account to fetch report data.

+ Connect to FFLogs +
+ + +
+

// FFLogs Report Viewer

+
Session expired. Please reconnect.
+ Reconnect to FFLogs +
+ + +

// FFLogs Report Viewer

+ +
+
+ + + Reconnect +
+
+ +
+ +
+ +
// paste a report code above and hit Fetch
+ + + + + + +
+ + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..f980089 --- /dev/null +++ b/js/app.js @@ -0,0 +1,95 @@ +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('report-form'); + const output = document.getElementById('output'); + const fightSelectRow = document.getElementById('fight-select-row'); + const fightSelect = document.getElementById('fight-select'); + + let allFights = []; + + function formatDuration(ms) { + const min = Math.floor(ms / 60000); + const sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); + return `${min}:${sec}`; + } + + function formatBossHp(fight) { + if (fight.kill) return 'Kill'; + const pct = fight.fightPercentage; + if (pct == null) return '?'; + // fightPercentage is 0–10000 (e.g. 5000 = 50.00%) + return (pct / 100).toFixed(2) + '%'; + } + + function displayFight(fight) { + output.textContent = JSON.stringify(fight, null, 2); + } + + fightSelect.addEventListener('change', () => { + const id = parseInt(fightSelect.value, 10); + const fight = allFights.find(f => f.id === id); + if (fight) displayFight(fight); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + output.textContent = '// fetching...'; + fightSelectRow.style.display = 'none'; + fightSelect.innerHTML = ''; + allFights = []; + + const params = new URLSearchParams({ + report_code: form.elements['report_code'].value.trim(), + }); + + let response, json; + try { + response = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + json = await response.json(); + } catch (err) { + output.textContent = '// network error: ' + err.message; + return; + } + + if (json.reauth) { + output.textContent = '// session expired — redirecting...'; + setTimeout(() => { window.location.href = 'auth/start.php'; }, 1500); + return; + } + + if (json.errors) { + output.textContent = '// GraphQL error:\n' + JSON.stringify(json.errors, null, 2); + return; + } + + const report = json?.data?.reportData?.report; + if (!report) { + output.textContent = JSON.stringify(json, null, 2); + return; + } + + allFights = report.fights ?? []; + + if (allFights.length === 0) { + output.textContent = '// Keine Fights in diesem Report gefunden.'; + return; + } + + // populate dropdown + allFights.forEach(fight => { + const duration = formatDuration(fight.endTime - fight.startTime); + const hp = formatBossHp(fight); + const label = `${fight.name} — ${duration} — ${hp}`; + const opt = document.createElement('option'); + opt.value = fight.id; + opt.textContent = label; + fightSelect.appendChild(opt); + }); + + fightSelectRow.style.display = 'flex'; + output.textContent = '// Fight auswählen ↑'; + }); +});