first commit

This commit is contained in:
Vlastislav Svatek
2026-04-26 02:23:11 +02:00
commit 153c83f7fa
31 changed files with 3804 additions and 0 deletions

276
api/oauth.php Normal file
View File

@@ -0,0 +1,276 @@
<?php
// ============================================================
// api/oauth.php — Twitch + Kick OAuth
// ============================================================
require_once __DIR__ . '/db.php';
cors();
start_session();
$provider = $_GET['provider'] ?? '';
$action = $_GET['action'] ?? '';
$code = $_GET['code'] ?? '';
$error = $_GET['error'] ?? '';
// ── Current user ─────────────────────────────────────────────
if ($action === 'me') {
start_session();
$user = $_SESSION['oauth_user'] ?? null;
$is_mod = false;
$banned = false;
if ($user) {
$stmt = db()->prepare("SELECT banned FROM users WHERE id=:uid");
$stmt->execute([':uid' => $user['id']]);
$row = $stmt->fetch();
$banned = !empty($row['banned']);
if ($banned) {
// Force logout banned user
unset($_SESSION['oauth_user']);
$user = null;
} else {
$stmt = db()->prepare("SELECT 1 FROM moderators WHERE user_id=:uid");
$stmt->execute([':uid' => $user['id']]);
$is_mod = (bool)$stmt->fetchColumn();
}
}
json_out([
'user' => $user,
'is_admin' => !empty($_SESSION['is_admin']),
'is_mod' => $is_mod,
'banned' => $banned,
]);
}
// ── Debug ─────────────────────────────────────────────────────
if ($action === 'debug') {
$states = db()->query("SELECT state, provider, created_at FROM oauth_state WHERE state != '__debug__' ORDER BY created_at DESC LIMIT 5")->fetchAll();
$debug_row = db()->prepare("SELECT verifier FROM oauth_state WHERE state='__debug__'")->execute() ? null : null;
$stmt = db()->prepare("SELECT verifier FROM oauth_state WHERE state='__debug__'");
$stmt->execute();
$debug_row = $stmt->fetch();
$kick_debug = $debug_row ? json_decode($debug_row['verifier'], true) : null;
json_out([
'session_id' => session_id(),
'oauth_user' => $_SESSION['oauth_user'] ?? null,
'kick_debug' => $kick_debug,
'twitch_redirect' => TWITCH_OAUTH_REDIRECT_URI,
'kick_redirect' => defined('KICK_OAUTH_REDIRECT_URI') ? KICK_OAUTH_REDIRECT_URI : '',
'twitch_client_set' => !empty(TWITCH_OAUTH_CLIENT_ID),
'kick_client_set' => !empty(KICK_OAUTH_CLIENT_ID),
'https_detected' => is_https(),
'recent_db_states' => $states,
]);
}
// ── Logout ────────────────────────────────────────────────────
if ($action === 'logout') {
unset($_SESSION['oauth_user']);
header('Location: /');
exit;
}
// ── Twitch OAuth ──────────────────────────────────────────────
if ($provider === 'twitch') {
if (empty($code) && empty($error)) {
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_provider'] = 'twitch';
$params = http_build_query([
'client_id' => TWITCH_OAUTH_CLIENT_ID,
'redirect_uri' => TWITCH_OAUTH_REDIRECT_URI,
'response_type' => 'code',
'scope' => 'user:read:email',
'state' => $state,
'force_verify' => 'false',
]);
header('Location: https://id.twitch.tv/oauth2/authorize?' . $params);
exit;
}
if (!empty($error)) { header('Location: /?auth_error=' . urlencode($error)); exit; }
if (($_GET['state'] ?? '') !== ($_SESSION['oauth_state'] ?? '')) {
header('Location: /?auth_error=invalid_state'); exit;
}
unset($_SESSION['oauth_state'], $_SESSION['oauth_provider']);
$resp = do_post('https://id.twitch.tv/oauth2/token', [], http_build_query([
'client_id' => TWITCH_OAUTH_CLIENT_ID,
'client_secret' => TWITCH_OAUTH_CLIENT_SECRET,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => TWITCH_OAUTH_REDIRECT_URI,
]));
if ($resp['status'] !== 200) { header('Location: /?auth_error=twitch_token_failed_' . $resp['status']); exit; }
$access_token = json_decode($resp['body'], true)['access_token'] ?? '';
if (empty($access_token)) { header('Location: /?auth_error=twitch_no_token'); exit; }
$resp = do_get('https://api.twitch.tv/helix/users', [
'Client-ID: ' . TWITCH_OAUTH_CLIENT_ID,
'Authorization: Bearer ' . $access_token,
]);
if ($resp['status'] !== 200) { header('Location: /?auth_error=twitch_user_failed'); exit; }
$tu = json_decode($resp['body'], true)['data'][0] ?? null;
if (!$tu) { header('Location: /?auth_error=twitch_no_user'); exit; }
$_SESSION['oauth_user'] = upsert_user('twitch', $tu['id'], $tu['login'], $tu['display_name'], $tu['profile_image_url'] ?? '');
header('Location: /');
exit;
}
// ── Kick OAuth (PKCE, state stored in DB) ────────────────────
if ($provider === 'kick') {
$kick_id = defined('KICK_OAUTH_CLIENT_ID') ? KICK_OAUTH_CLIENT_ID : '';
$kick_secret = defined('KICK_OAUTH_CLIENT_SECRET') ? KICK_OAUTH_CLIENT_SECRET : '';
$kick_uri = defined('KICK_OAUTH_REDIRECT_URI') ? KICK_OAUTH_REDIRECT_URI : '';
if (empty($kick_id)) { header('Location: /?auth_error=kick_not_configured'); exit; }
if (empty($code) && empty($error)) {
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$state = bin2hex(random_bytes(16));
// Store verifier in DB — session is unreliable across OAuth redirect
db()->prepare("DELETE FROM oauth_state WHERE created_at < NOW() - INTERVAL '10 minutes'")->execute();
db()->prepare("INSERT INTO oauth_state (state, provider, verifier) VALUES (:s,'kick',:v) ON CONFLICT (state) DO UPDATE SET verifier=EXCLUDED.verifier")
->execute([':s' => $state, ':v' => $verifier]);
$params = http_build_query([
'client_id' => $kick_id,
'redirect_uri' => $kick_uri,
'response_type' => 'code',
'scope' => 'user:read channel:read',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
header('Location: https://id.kick.com/oauth/authorize?' . $params);
exit;
}
if (!empty($error)) { header('Location: /?auth_error=' . urlencode($error)); exit; }
// Retrieve verifier from DB using state
$stmt = db()->prepare("SELECT verifier FROM oauth_state WHERE state=:s AND provider='kick'");
$stmt->execute([':s' => $_GET['state'] ?? '']);
$row = $stmt->fetch();
if (!$row) { header('Location: /?auth_error=kick_invalid_state'); exit; }
$verifier = $row['verifier'];
db()->prepare("DELETE FROM oauth_state WHERE state=:s")->execute([':s' => $_GET['state']]);
$fields = [
'grant_type' => 'authorization_code',
'client_id' => $kick_id,
'redirect_uri' => $kick_uri,
'code' => $code,
'code_verifier' => $verifier,
];
if (!empty($kick_secret)) $fields['client_secret'] = $kick_secret;
$resp_token = do_post('https://id.kick.com/oauth/token', [], http_build_query($fields));
if ($resp_token['status'] !== 200) {
$err = json_decode($resp_token['body'], true);
$msg = $err['error'] ?? $err['message'] ?? $err['error_description'] ?? '';
if (empty($msg)) {
$_SESSION['kick_debug'] = ['status' => $resp_token['status'], 'body' => substr($resp_token['body'], 0, 500), 'fields' => $fields];
header('Location: /?auth_error=kick_token_' . $resp_token['status'] . '_see_debug');
} else {
header('Location: /?auth_error=' . urlencode('kick_token_' . $resp_token['status'] . '_' . $msg));
}
exit;
}
$token_data = json_decode($resp_token['body'], true);
$access_token = $token_data['access_token'] ?? '';
$resp_token_raw = $resp_token['body'];
if (empty($access_token)) { header('Location: /?auth_error=kick_no_token'); exit; }
$auth_header = ['Authorization: Bearer ' . $access_token, 'Accept: application/json'];
// Correct endpoint per Kick docs — GET /public/v1/users returns current user
$resp_user = do_get('https://api.kick.com/public/v1/users', $auth_header);
$debug = json_encode([
'introspect_scope' => json_decode($resp_token_raw, true)['scope'] ?? '',
'users_endpoint' => ['s' => $resp_user['status'], 'b' => substr($resp_user['body'], 0, 500)],
]);
db()->prepare("INSERT INTO oauth_state (state, provider, verifier) VALUES ('__debug__','kick_debug',:d) ON CONFLICT (state) DO UPDATE SET verifier=EXCLUDED.verifier, created_at=NOW()")
->execute([':d' => $debug]);
if ($resp_user['status'] !== 200) {
header('Location: /?auth_error=kick_user_failed_' . $resp_user['status'] . '_see_debug');
exit;
}
$ku = json_decode($resp_user['body'], true) ?? [];
// Handle potential data wrapper
if (isset($ku['data'])) $ku = $ku['data'];
if (isset($ku[0])) $ku = $ku[0];
$ku_id = $ku['id'] ?? $ku['user_id'] ?? null;
$ku_login = $ku['username'] ?? $ku['slug'] ?? $ku['name'] ?? '';
$ku_display = $ku['name'] ?? $ku['username'] ?? $ku_login;
$ku_avatar = $ku['profile_picture'] ?? $ku['profile_pic'] ?? $ku['picture'] ?? '';
if (empty($ku_id)) { header('Location: /?auth_error=kick_no_user'); exit; }
$_SESSION['oauth_user'] = upsert_user('kick', (string)$ku_id, $ku_login, $ku_display, $ku_avatar);
header('Location: /');
exit;
}
json_error('Not found', 404);
// ── Helpers ───────────────────────────────────────────────────
function do_post(string $url, array $extra_headers, string $body): array {
$default_headers = empty($body) ? ['Accept: application/json'] : ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'];
$headers = array_merge($default_headers, $extra_headers);
$ch = curl_init($url);
$opts = [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_HTTPHEADER=>$headers, CURLOPT_TIMEOUT=>10];
if (!empty($body)) $opts[CURLOPT_POSTFIELDS] = $body;
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
return ['status' => $status, 'body' => (string)$resp];
}
function do_get(string $url, array $extra_headers = []): array {
$headers = array_merge(['Accept: application/json'], $extra_headers);
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>$headers, CURLOPT_TIMEOUT=>10]);
$resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
return ['status' => $status, 'body' => (string)$resp];
}
function upsert_user(string $provider, string $pid, string $login, string $display, string $avatar): array {
$stmt = db()->prepare("
INSERT INTO users (provider, provider_id, login, display_name, avatar, last_seen)
VALUES (:p,:pid,:l,:d,:a,NOW())
ON CONFLICT (provider, provider_id) DO UPDATE SET
login=EXCLUDED.login, display_name=EXCLUDED.display_name,
avatar=EXCLUDED.avatar, last_seen=NOW()
RETURNING id, provider, login, display_name, avatar
");
$stmt->execute([':p'=>$provider,':pid'=>$pid,':l'=>$login,':d'=>$display,':a'=>$avatar]);
return $stmt->fetch();
}
function is_https(): bool {
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
|| (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443);
}