Initial commit: FFLogs PKCE OAuth viewer

This commit is contained in:
xziino 2026-05-20 08:05:09 +02:00
commit cf188c1727
8 changed files with 570 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.claude/
debug/
fflogs-schema.json

48
CLAUDE.md Normal file
View File

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

98
api/fight.php Normal file
View File

@ -0,0 +1,98 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
session_start_safe();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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;

67
auth/callback.php Normal file
View File

@ -0,0 +1,67 @@
<?php
require_once __DIR__ . '/../config.php';
session_start_safe();
// user denied access
if (isset($_GET['error'])) {
header('Location: ../index.php?error=' . urlencode($_GET['error']));
exit;
}
// CSRF check
if (
empty($_GET['state']) ||
empty($_SESSION['oauth_state']) ||
!hash_equals($_SESSION['oauth_state'], $_GET['state'])
) {
session_destroy();
http_response_code(400);
echo 'Invalid state parameter. Possible CSRF attack. <a href="../index.php">Back</a>';
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;

22
auth/start.php Normal file
View File

@ -0,0 +1,22 @@
<?php
require_once __DIR__ . '/../config.php';
session_start_safe();
$verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$state = bin2hex(random_bytes(16));
$_SESSION['pkce_verifier'] = $verifier;
$_SESSION['oauth_state'] = $state;
$params = http_build_query([
'response_type' => 'code',
'client_id' => CLIENT_ID,
'redirect_uri' => REDIRECT_URI,
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
header('Location: ' . AUTHORIZE_URI . '?' . $params);
exit;

21
config.php Normal file
View File

@ -0,0 +1,21 @@
<?php
define('DEV_MODE', true); // set to false in production
define('CLIENT_ID', 'a1d27cba-b7f8-48dd-aefd-4697b457cc67');
define('REDIRECT_URI', 'http://localhost:8080/auth/callback.php');
define('AUTHORIZE_URI','https://www.fflogs.com/oauth/authorize');
define('TOKEN_URI', 'https://www.fflogs.com/oauth/token');
define('GRAPHQL_URI', 'https://www.fflogs.com/api/v2/user');
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => false, // set to true in production (HTTPS)
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
}

216
index.php Normal file
View File

@ -0,0 +1,216 @@
<?php
require_once __DIR__ . '/config.php';
session_start_safe();
$authenticated = !empty($_SESSION['access_token'])
&& ($_SESSION['token_expires'] ?? 0) > 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']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FFLogs Report Viewer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', Courier, monospace;
background: #1a1a1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container {
width: 100%;
max-width: 860px;
}
h1 {
font-size: 1.2rem;
color: #a78bfa;
margin-bottom: 1.5rem;
letter-spacing: 0.05em;
}
.error-box {
background: #3b0f0f;
border: 1px solid #7f1d1d;
color: #fca5a5;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 2px;
font-size: 0.9rem;
}
.info-box {
background: #1e2a1e;
border: 1px solid #365936;
color: #86efac;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 2px;
font-size: 0.9rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
background: #7c3aed;
color: #fff;
text-decoration: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
border-radius: 2px;
letter-spacing: 0.03em;
transition: background 0.15s;
}
.btn:hover { background: #6d28d9; }
.btn-secondary {
background: #374151;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
margin-left: 1rem;
}
.btn-secondary:hover { background: #4b5563; }
.form-row {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
input[type="text"], select {
flex: 1;
min-width: 180px;
padding: 0.55rem 0.75rem;
background: #111827;
border: 1px solid #374151;
color: #e0e0e0;
font-family: inherit;
font-size: 0.95rem;
border-radius: 2px;
outline: none;
}
input[type="text"]:focus, select:focus { border-color: #7c3aed; }
input[type="text"]::placeholder { color: #4b5563; }
select option { background: #111827; }
#fight-select-row { display: none; }
.terminal {
background: #0d0d0d;
border: 1px solid #2d2d2d;
color: #a3e635;
padding: 1.25rem;
min-height: 260px;
white-space: pre-wrap;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.55;
border-radius: 2px;
}
.footer-link {
margin-top: 1rem;
font-size: 0.8rem;
color: #4b5563;
}
.footer-link a { color: #6b7280; text-decoration: none; }
.footer-link a:hover { color: #9ca3af; }
.connect-area {
text-align: center;
}
.connect-area h1 { margin-bottom: 0.5rem; }
.connect-area p {
color: #6b7280;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<?php if ($errorText): ?>
<div class="error-box"><?= $errorText ?></div>
<?php endif; ?>
<?php if ($tokenDebug): ?>
<div class="error-box">
<strong>Debug FFLogs Token Response:</strong><br>
HTTP <?= (int)$tokenDebug['http_status'] ?><?= $tokenDebug['curl_error'] ? ' | curl: ' . htmlspecialchars($tokenDebug['curl_error']) : '' ?><br>
<pre style="margin-top:0.5rem;white-space:pre-wrap;font-size:0.8rem;"><?= htmlspecialchars($tokenDebug['response_body'] ?? '(empty)') ?></pre>
</div>
<?php endif; ?>
<?php if (!$authenticated && !$tokenExpired): ?>
<div class="connect-area">
<h1>// FFLogs Report Viewer</h1>
<p>Connect your FFLogs account to fetch report data.</p>
<a class="btn" href="auth/start.php">Connect to FFLogs</a>
</div>
<?php elseif ($tokenExpired): ?>
<div class="connect-area">
<h1>// FFLogs Report Viewer</h1>
<div class="info-box">Session expired. Please reconnect.</div>
<a class="btn" href="auth/start.php">Reconnect to FFLogs</a>
</div>
<?php else: ?>
<h1>// FFLogs Report Viewer</h1>
<form id="report-form">
<div class="form-row">
<input
type="text"
name="report_code"
placeholder="Report Code (e.g. aBcDeFgH1234)"
autocomplete="off"
spellcheck="false"
required
>
<button class="btn" type="submit">Fetch</button>
<a class="btn btn-secondary" href="auth/start.php">Reconnect</a>
</div>
</form>
<div class="form-row" id="fight-select-row">
<select id="fight-select">
<option value=""> Fight auswählen </option>
</select>
</div>
<pre id="output" class="terminal">// paste a report code above and hit Fetch</pre>
<div class="footer-link">
Token valid until: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?>
</div>
<script src="js/app.js"></script>
<?php endif; ?>
</div>
</body>
</html>

95
js/app.js Normal file
View File

@ -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 010000 (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 = '<option value="">— Fight auswählen —</option>';
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 ↑';
});
});