Initial commit: FFLogs PKCE OAuth viewer
This commit is contained in:
commit
cf188c1727
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.claude/
|
||||
debug/
|
||||
fflogs-schema.json
|
||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal 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
98
api/fight.php
Normal 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
67
auth/callback.php
Normal 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
22
auth/start.php
Normal 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
21
config.php
Normal 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
216
index.php
Normal 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
95
js/app.js
Normal 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 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 = '<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 ↑';
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user