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

45
api/auth.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
// ============================================================
// api/auth.php — POST /api/auth/login | POST /api/auth/logout
// GET /api/auth/check
// ============================================================
require_once __DIR__ . '/db.php';
cors();
start_session();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// GET /api/auth/check
if ($method === 'GET' && $action === 'check') {
json_out(['admin' => !empty($_SESSION['is_admin'])]);
}
// POST /api/auth/login
if ($method === 'POST' && $action === 'login') {
$body = body();
$pw = $body['password'] ?? '';
if (empty($pw)) {
json_error('Password required');
}
if (!password_verify($pw, ADMIN_HASH)) {
sleep(1);
json_error('Invalid password', 401);
}
session_regenerate_id(true);
$_SESSION['is_admin'] = true;
json_out(['ok' => true]);
}
// POST /api/auth/logout — clears admin only, keeps OAuth user logged in
if ($method === 'POST' && $action === 'logout') {
unset($_SESSION['is_admin']);
json_out(['ok' => true]);
}
json_error('Not found', 404);

Binary file not shown.

122
api/comments.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
// ============================================================
// api/comments.php
//
// GET /api/comments?streamer_id=N — načti komentáře (veřejné)
// POST /api/comments — přidej komentář
// DELETE /api/comments?id=N — smaž komentář (admin)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
$method = $_SERVER['REQUEST_METHOD'];
// ------------------------------------------------------------------
// GET — komentáře pro daného streamera
// ------------------------------------------------------------------
if ($method === 'GET') {
$sid = (int)($_GET['streamer_id'] ?? 0);
if (!$sid) json_error('Missing streamer_id');
$rows = db()->prepare("
SELECT c.id, c.author, c.body, c.is_admin, c.created_at, c.user_id,
u.display_name AS user_display, u.avatar AS user_avatar, u.provider AS user_provider,
COALESCE(
(SELECT array_agg(rgm.group_id ORDER BY rgm.group_id)
FROM rater_group_members rgm WHERE rgm.user_id = c.user_id),
ARRAY[]::int[]
) AS team_ids
FROM comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.streamer_id = :sid
ORDER BY c.created_at ASC
");
$rows->execute([':sid' => $sid]);
$rs = $rows->fetchAll();
// Postgres returns array as PHP-native array via PDO; ensure shape
foreach ($rs as &$r) {
if (is_string($r['team_ids'])) {
// Fallback: parse "{1,2,3}" string
$r['team_ids'] = array_filter(array_map('intval', explode(',', trim($r['team_ids'], '{}'))), fn($x)=>$x>0);
}
$r['team_ids'] = array_values(array_map('intval', $r['team_ids'] ?? []));
}
unset($r);
json_out($rs);
}
// ------------------------------------------------------------------
// POST — přidat komentář
// ------------------------------------------------------------------
if ($method === 'POST') {
start_session();
$body = body();
$sid = (int)($body['streamer_id'] ?? 0);
$text = trim($body['body'] ?? '');
if (!$sid) json_error('Missing streamer_id');
if (strlen($text) < 2) json_error('Komentář je příliš krátký');
if (strlen($text) > 1000) json_error('Komentář je příliš dlouhý (max 1000 znaků)');
// Check auth settings
$settings_row = db()->query("SELECT key, value FROM settings")->fetchAll();
$settings = [];
foreach ($settings_row as $r) $settings[$r['key']] = $r['value'];
$auth_enabled = ($settings['auth_enabled'] ?? 'false') === 'true';
$oauth_user = $_SESSION['oauth_user'] ?? null;
$is_admin = !empty($_SESSION['is_admin']);
// If auth required and user not logged in (and not admin)
if ($auth_enabled && !$oauth_user && !$is_admin) {
json_error('Pro komentování je vyžadováno přihlášení', 401);
}
// Determine author name and user_id
if ($oauth_user) {
$author = $oauth_user['display_name'];
$user_id = $oauth_user['id'];
} else {
$author = mb_substr(trim($body['author'] ?? 'Anonym'), 0, 50) ?: 'Anonym';
$user_id = null;
}
$stmt = db()->prepare("
INSERT INTO comments (streamer_id, user_id, author, body, is_admin)
VALUES (:sid, :uid, :author, :body, :is_admin)
RETURNING id, author, body, is_admin, created_at
");
$stmt->execute([
':sid' => $sid,
':uid' => $user_id,
':author' => $author,
':body' => $text,
':is_admin' => $is_admin ? 'true' : 'false',
]);
$comment = $stmt->fetch();
// Add user info to response
if ($oauth_user) {
$comment['user_display'] = $oauth_user['display_name'];
$comment['user_avatar'] = $oauth_user['avatar'];
$comment['user_provider'] = $oauth_user['provider'];
}
json_out($comment, 201);
}
// ------------------------------------------------------------------
// DELETE — smazat komentář (admin only)
// ------------------------------------------------------------------
if ($method === 'DELETE') {
require_admin();
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
db()->prepare("DELETE FROM comments WHERE id = :id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.

228
api/community_ratings.php Normal file
View File

@@ -0,0 +1,228 @@
<?php
// ============================================================
// api/community_ratings.php
//
// GET /api/community_ratings?streamer_id=N — get all group ratings for a streamer (public)
// GET /api/community_ratings?my_rating=N — get my group's rating for a streamer (rater)
// PUT /api/community_ratings?streamer_id=N — submit/update group rating (rater/admin)
// DELETE /api/community_ratings?id=N — delete a rating (admin)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
start_session();
$method = $_SERVER['REQUEST_METHOD'];
// ── GET ────────────────────────────────────────────────────────
if ($method === 'GET') {
// Batch: all ratings for a single team (member of the team or admin).
// /api/community_ratings?team_ratings=1&group_id=N
if (isset($_GET['team_ratings'])) {
$gid = (int)($_GET['group_id'] ?? 0);
if (!$gid) json_error('Missing group_id');
$is_admin = !empty($_SESSION['is_admin']);
$user = $_SESSION['oauth_user'] ?? null;
// Members of this team or admin only
if (!$is_admin) {
if (!$user) json_error('Not logged in', 401);
$stmt = db()->prepare("SELECT 1 FROM rater_group_members WHERE group_id=:gid AND user_id=:uid");
$stmt->execute([':gid' => $gid, ':uid' => $user['id']]);
if (!$stmt->fetchColumn()) json_error('Not a member of this team', 403);
}
$stmt = db()->prepare("
SELECT streamer_id, statistiky, grafika, alerty, vychytavky,
nahled, nastaveni, odlisnost, notes, updated_at
FROM community_ratings
WHERE group_id = :gid
");
$stmt->execute([':gid' => $gid]);
// Return as map: { streamer_id: rating }
$out = [];
foreach ($stmt->fetchAll() as $r) {
$out[(int)$r['streamer_id']] = $r;
}
json_out($out);
}
// My group's rating for a specific streamer.
// Optional &group_id=N to disambiguate when user is in multiple teams.
if (isset($_GET['my_rating'])) {
$sid = (int)($_GET['my_rating'] ?? 0);
$gid = isset($_GET['group_id']) ? (int)$_GET['group_id'] : 0;
$user = $_SESSION['oauth_user'] ?? null;
if (!$user) json_out(null);
$sql = "
SELECT cr.*, g.name AS group_name
FROM community_ratings cr
JOIN rater_groups g ON g.id = cr.group_id
JOIN rater_group_members m ON m.group_id = g.id
WHERE m.user_id = :uid AND cr.streamer_id = :sid
";
$params = [':uid' => $user['id'], ':sid' => $sid];
if ($gid) {
$sql .= " AND cr.group_id = :gid";
$params[':gid'] = $gid;
}
$sql .= " LIMIT 1";
$stmt = db()->prepare($sql);
$stmt->execute($params);
json_out($stmt->fetch() ?: null);
}
// All community ratings for a streamer — public, anonymous
$sid = (int)($_GET['streamer_id'] ?? 0);
if (!$sid) json_error('Missing streamer_id');
$stmt = db()->prepare("
SELECT cr.id, cr.group_id, g.name AS group_name,
cr.statistiky, cr.grafika, cr.alerty, cr.vychytavky,
cr.nahled, cr.nastaveni, cr.odlisnost, cr.notes, cr.updated_at
FROM community_ratings cr
JOIN rater_groups g ON g.id = cr.group_id
WHERE cr.streamer_id = :sid
ORDER BY cr.updated_at DESC
");
$stmt->execute([':sid' => $sid]);
$ratings = $stmt->fetchAll();
// Calculate averages
$avg = null;
$keymap = ['s'=>'statistiky','g'=>'grafika','a'=>'alerty','v'=>'vychytavky','n'=>'nahled','ns'=>'nastaveni','o'=>'odlisnost'];
if ($ratings) {
$keys = ['statistiky','grafika','alerty','vychytavky','nahled','nastaveni','odlisnost'];
$avg = [];
foreach ($keys as $k) {
$avg[$k] = round(array_sum(array_column($ratings, $k)) / count($ratings), 1);
}
// Also expose compact keys for frontend
foreach ($keymap as $short => $long) $avg[$short] = $avg[$long];
$avg['total'] = round(array_sum(array_intersect_key($avg, array_flip($keys))), 1);
$avg['count'] = count($ratings);
}
// Add compact keys to each rating row too
foreach ($ratings as &$r) {
foreach ($keymap as $short => $long) $r[$short] = (int)$r[$long];
}
unset($r);
json_out(['ratings' => $ratings, 'avg' => $avg]);
}
// ── PUT — submit or update group rating ───────────────────────
if ($method === 'PUT') {
$sid = (int)($_GET['streamer_id'] ?? 0);
if (!$sid) json_error('Missing streamer_id');
$is_admin = !empty($_SESSION['is_admin']);
$user = $_SESSION['oauth_user'] ?? null;
if (!$is_admin && !$user) json_error('Not logged in', 401);
// Check streamer not locked
$stmt = db()->prepare("SELECT community_locked FROM streamers WHERE id=:id");
$stmt->execute([':id' => $sid]);
$row = $stmt->fetch();
if (!$row) json_error('Streamer not found', 404);
if ($row['community_locked'] && !$is_admin) json_error('Community ratings are locked for this streamer', 403);
$body = body();
// Determine group_id
if (isset($body['group_id'])) {
$group_id = (int)$body['group_id'];
// Members must be in this group; admin can use any group
if (!$is_admin) {
$stmt = db()->prepare("
SELECT 1 FROM rater_group_members
WHERE group_id = :gid AND user_id = :uid
");
$stmt->execute([':gid' => $group_id, ':uid' => $user['id']]);
if (!$stmt->fetchColumn()) json_error('You are not a member of this team', 403);
}
} elseif ($user) {
// Default: pick the user's first group
$stmt = db()->prepare("
SELECT group_id FROM rater_group_members WHERE user_id=:uid LIMIT 1
");
$stmt->execute([':uid' => $user['id']]);
$row = $stmt->fetch();
if (!$row) json_error('You are not assigned to any rater group', 403);
$group_id = (int)$row['group_id'];
} else {
json_error('Cannot determine group', 400);
}
// Block self-rating: a team representing a streamer cannot rate that streamer
$stmt = db()->prepare("SELECT streamer_id FROM rater_groups WHERE id = :gid");
$stmt->execute([':gid' => $group_id]);
$linked_sid = $stmt->fetchColumn();
if ($linked_sid && (int)$linked_sid === $sid) {
json_error('A team cannot rate its own streamer', 403);
}
$clamp = fn($v) => max(0, min(10, (int)$v));
$r = $body['r'] ?? [];
db()->prepare("
INSERT INTO community_ratings
(group_id, streamer_id, statistiky, grafika, alerty, vychytavky,
nahled, nastaveni, odlisnost, notes, updated_by, updated_at)
VALUES
(:gid, :sid, :s, :g, :a, :v, :n, :ns, :o, :notes, :uid, NOW())
ON CONFLICT (group_id, streamer_id) DO UPDATE SET
statistiky = EXCLUDED.statistiky,
grafika = EXCLUDED.grafika,
alerty = EXCLUDED.alerty,
vychytavky = EXCLUDED.vychytavky,
nahled = EXCLUDED.nahled,
nastaveni = EXCLUDED.nastaveni,
odlisnost = EXCLUDED.odlisnost,
notes = EXCLUDED.notes,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
")->execute([
':gid' => $group_id,
':sid' => $sid,
':s' => $clamp($r['s'] ?? 0),
':g' => $clamp($r['g'] ?? 0),
':a' => $clamp($r['a'] ?? 0),
':v' => $clamp($r['v'] ?? 0),
':n' => $clamp($r['n'] ?? 0),
':ns' => $clamp($r['ns'] ?? 0),
':o' => $clamp($r['o'] ?? 0),
':notes' => substr(trim($body['notes'] ?? ''), 0, 1000),
':uid' => $user ? $user['id'] : null,
]);
json_out(['ok' => true]);
}
// ── DELETE — admin only ────────────────────────────────────────
if ($method === 'DELETE') {
require_admin();
// Wipe all ratings for a single streamer (used by reset)
if (isset($_GET['wipe_streamer'])) {
$sid = (int)$_GET['wipe_streamer'];
if (!$sid) json_error('Missing streamer id');
$stmt = db()->prepare("DELETE FROM community_ratings WHERE streamer_id = :sid");
$stmt->execute([':sid' => $sid]);
json_out(['ok' => true, 'deleted' => $stmt->rowCount()]);
}
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
db()->prepare("DELETE FROM community_ratings WHERE id=:id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.

128
api/db.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
// ============================================================
// api/db.php — shared PDO connection + helpers
// ============================================================
require_once __DIR__ . '/../config.php';
// Ensure PHP errors never output HTML into JSON responses
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
error_reporting(E_ALL);
// Convert PHP errors to exceptions so they get caught properly
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
// Catch fatal errors and return JSON
register_shutdown_function(function(): void {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(['error' => 'Internal server error', 'detail' => $err['message']]);
}
});
// Start session once, safely
// For OAuth flows (cross-site redirect), SameSite must be Lax (not Strict)
function start_session(): void {
if (session_status() === PHP_SESSION_NONE) {
$is_https = (!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);
session_set_cookie_params([
'lifetime' => 86400,
'path' => '/',
'secure' => $is_https,
'httponly' => true,
'samesite' => 'Lax', // Lax allows cookie on redirect from OAuth provider
]);
session_start();
}
}
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf(
'pgsql:host=%s;port=%s;dbname=%s',
DB_HOST, DB_PORT, DB_NAME
);
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}
function json_out(mixed $data, int $status = 200): never {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function json_error(string $msg, int $status = 400): never {
json_out(['error' => $msg], $status);
}
function cors(): void {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// credentials:include requires explicit origin, not wildcard
if ($origin !== '') {
header('Access-Control-Allow-Origin: ' . $origin);
} else {
header('Access-Control-Allow-Origin: *');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
}
function require_admin(): void {
start_session();
if (empty($_SESSION['is_admin'])) {
json_error('Unauthorized', 401);
}
}
function is_moderator(): bool {
start_session();
if (!empty($_SESSION['is_admin'])) return true;
$user = $_SESSION['oauth_user'] ?? null;
if (!$user) return false;
$stmt = db()->prepare("
SELECT 1 FROM moderators m
JOIN users u ON u.id = m.user_id
WHERE m.user_id = :uid AND (u.banned IS NULL OR u.banned = false)
");
$stmt->execute([':uid' => $user['id']]);
return (bool)$stmt->fetchColumn();
}
function require_mod(): void {
if (!is_moderator()) {
json_error('Unauthorized — moderator access required', 401);
}
}
function body(): array {
$raw = file_get_contents('php://input');
return json_decode($raw, true) ?? [];
}
function sanitize_name(string $name): string {
return strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', trim($name)));
}

BIN
api/db.php:Zone.Identifier Normal file

Binary file not shown.

225
api/live.php Normal file
View File

@@ -0,0 +1,225 @@
<?php
// ============================================================
// api/live.php
//
// GET /api/live — public, returns current DB status
// GET /api/live?refresh=1 — admin only, fetches from Twitch/Kick API
// ============================================================
require_once __DIR__ . '/db.php';
cors();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
json_error('Method not allowed', 405);
}
// Public request — just return current status from DB
if (!isset($_GET['refresh'])) {
$rows = db()->query("
SELECT id, name, platform, kick_name, status, game, title
FROM streamers ORDER BY name
")->fetchAll();
json_out($rows);
}
// Refresh request — admin only
require_admin();
$cache_file = '/tmp/snb_live_cache.json';
$now = time();
// Return cached result if still fresh
if (file_exists($cache_file)) {
$cache = json_decode(file_get_contents($cache_file), true);
if ($cache && ($now - $cache['ts']) < LIVE_CACHE_TTL) {
json_out(['cached' => true, 'age' => $now - $cache['ts'], 'results' => $cache['results']]);
}
}
// Fetch all streamers from DB
$rows = db()->query("
SELECT id, name, platform, kick_name FROM streamers ORDER BY name
")->fetchAll();
$twitch_streamers = [];
$kick_streamers = [];
foreach ($rows as $row) {
if ($row['platform'] === 'kick' && !empty($row['kick_name'])) {
$kick_streamers[] = $row;
} else {
$twitch_streamers[] = $row;
}
}
$results = [];
// ------------------------------------------------------------------
// Twitch — Helix API
// ------------------------------------------------------------------
if ($twitch_streamers) {
$token = get_twitch_token();
if ($token) {
foreach (array_chunk($twitch_streamers, 100) as $batch) {
// Build URL with multiple login= params
$params = array_map(
fn($s) => 'user_login=' . urlencode(strtolower($s['name'])),
$batch
);
$url = 'https://api.twitch.tv/helix/streams?' . implode('&', $params) . '&first=100';
$response = twitch_get($url, $token);
// Build lookup by login name
$live = [];
if ($response && isset($response['data'])) {
foreach ($response['data'] as $stream) {
$live[strtolower($stream['user_login'])] = $stream;
}
}
foreach ($batch as $s) {
$login = strtolower($s['name']);
$stream = $live[$login] ?? null;
$results[$s['id']] = [
'id' => $s['id'],
'name' => $s['name'],
'status' => $stream ? 'live' : 'offline',
'game' => $stream['game_name'] ?? '',
'title' => $stream['title'] ?? '',
];
}
}
} else {
foreach ($twitch_streamers as $s) {
$results[$s['id']] = [
'id' => $s['id'], 'name' => $s['name'],
'status' => 'unknown', 'game' => '', 'title' => '',
];
}
}
}
// ------------------------------------------------------------------
// Kick — public API
// ------------------------------------------------------------------
foreach ($kick_streamers as $s) {
$kick_name = $s['kick_name'] ?: $s['name'];
// Kick public channel API — no auth required
$url = 'https://kick.com/api/v2/channels/' . urlencode(strtolower($kick_name));
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Accept-Language: en-US,en;q=0.9',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = ($status === 200 && $body) ? json_decode($body, true) : null;
// livestream key is present and non-null when live
$is_live = !empty($data['livestream']);
$results[$s['id']] = [
'id' => $s['id'],
'name' => $s['name'],
'status' => $is_live ? 'live' : 'offline',
'game' => $data['livestream']['categories'][0]['name'] ?? '',
'title' => $data['livestream']['session_title'] ?? '',
];
}
// ------------------------------------------------------------------
// Write results back to DB
// ------------------------------------------------------------------
if ($results) {
$db = db();
$stmt = $db->prepare("
UPDATE streamers
SET status = :status, game = :game, title = :title
WHERE id = :id
");
foreach ($results as $r) {
if ($r['status'] === 'unknown') continue;
$stmt->execute([
':status' => $r['status'],
':game' => $r['game'],
':title' => $r['title'],
':id' => $r['id'],
]);
}
}
// Cache
file_put_contents($cache_file, json_encode([
'ts' => $now,
'results' => array_values($results),
]));
json_out(['cached' => false, 'results' => array_values($results)]);
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function get_twitch_token(): ?string {
$cache_file = '/tmp/snb_twitch_token.json';
if (file_exists($cache_file)) {
$cached = json_decode(file_get_contents($cache_file), true);
if ($cached && $cached['expires_at'] > time() + 3600) {
return $cached['access_token'];
}
}
$ch = curl_init('https://id.twitch.tv/oauth2/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'client_id' => TWITCH_CLIENT_ID,
'client_secret' => TWITCH_CLIENT_SECRET,
'grant_type' => 'client_credentials',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) return null;
$data = json_decode($body, true);
if (empty($data['access_token'])) return null;
file_put_contents($cache_file, json_encode([
'access_token' => $data['access_token'],
'expires_at' => time() + ($data['expires_in'] ?? 3600),
]));
return $data['access_token'];
}
function twitch_get(string $url, string $token): ?array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Client-ID: ' . TWITCH_CLIENT_ID,
'Authorization: Bearer ' . $token,
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ($status === 200) ? json_decode($body, true) : null;
}

Binary file not shown.

106
api/moderators.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
// ============================================================
// api/moderators.php
//
// GET /api/moderators — list moderators (admin)
// GET /api/moderators?users=1 — list all OAuth users (admin)
// POST /api/moderators — add mod by username (admin)
// DELETE /api/moderators?id=N — revoke mod (admin)
// POST /api/moderators?ban=1 — ban user (admin)
// DELETE /api/moderators?unban=N — unban user (admin)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
require_admin();
$method = $_SERVER['REQUEST_METHOD'];
// ------------------------------------------------------------------
// GET
// ------------------------------------------------------------------
if ($method === 'GET') {
if (isset($_GET['users'])) {
// All OAuth users with their role
$rows = db()->query("
SELECT u.id, u.provider, u.login, u.display_name, u.avatar,
u.created_at, u.last_seen, u.banned,
CASE WHEN m.id IS NOT NULL THEN true ELSE false END AS is_mod
FROM users u
LEFT JOIN moderators m ON m.user_id = u.id
ORDER BY u.last_seen DESC
LIMIT 200
")->fetchAll();
json_out($rows);
}
// Moderators list
$rows = db()->query("
SELECT m.id AS mod_id, m.created_at, m.granted_by,
u.id, u.provider, u.login, u.display_name, u.avatar
FROM moderators m
JOIN users u ON u.id = m.user_id
ORDER BY m.created_at DESC
")->fetchAll();
json_out($rows);
}
// ------------------------------------------------------------------
// POST — add mod or ban
// ------------------------------------------------------------------
if ($method === 'POST') {
// Ban user
if (isset($_GET['ban'])) {
$body = body();
$user_id = (int)($body['user_id'] ?? 0);
if (!$user_id) json_error('Missing user_id');
db()->prepare("UPDATE users SET banned=true WHERE id=:id")->execute([':id' => $user_id]);
// Also remove mod if banned
db()->prepare("DELETE FROM moderators WHERE user_id=:id")->execute([':id' => $user_id]);
json_out(['ok' => true]);
}
// Add moderator by username
$body = body();
$login = strtolower(trim($body['login'] ?? ''));
$provider = in_array($body['provider'] ?? '', ['twitch','kick']) ? $body['provider'] : 'twitch';
if (empty($login)) json_error('Missing login');
$stmt = db()->prepare("SELECT id, display_name FROM users WHERE LOWER(login)=:l AND provider=:p AND (banned IS NULL OR banned=false)");
$stmt->execute([':l' => $login, ':p' => $provider]);
$user = $stmt->fetch();
if (!$user) {
json_error("Uživatel '$login' na $provider se zatím nepřihlásil nebo je zabanován.", 404);
}
db()->prepare("INSERT INTO moderators (user_id, granted_by) VALUES (:uid,'admin') ON CONFLICT (user_id) DO NOTHING")
->execute([':uid' => $user['id']]);
json_out(['ok' => true, 'display_name' => $user['display_name']]);
}
// ------------------------------------------------------------------
// DELETE — revoke mod or unban
// ------------------------------------------------------------------
if ($method === 'DELETE') {
// Unban
if (isset($_GET['unban'])) {
$user_id = (int)($_GET['unban'] ?? 0);
if (!$user_id) json_error('Missing user_id');
db()->prepare("UPDATE users SET banned=false WHERE id=:id")->execute([':id' => $user_id]);
json_out(['ok' => true]);
}
// Remove mod
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
db()->prepare("DELETE FROM moderators WHERE id=:id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.

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);
}

Binary file not shown.

172
api/rater_groups.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
// ============================================================
// api/rater_groups.php
//
// GET /api/rater_groups — list groups + members + linked streamer (admin)
// GET /api/rater_groups?my=1 — my group(s) info (rater); returns array
// POST /api/rater_groups — create group, optional streamer_id (admin)
// PUT /api/rater_groups?id=N — update group (rename, set/unset streamer_id) (admin)
// DELETE /api/rater_groups?id=N — delete group (admin)
// POST /api/rater_groups?members=1 — add member to group (admin)
// DELETE /api/rater_groups?member=N — remove member (admin)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
start_session();
$method = $_SERVER['REQUEST_METHOD'];
// ── My groups — for logged-in raters (returns array of groups) ─
if ($method === 'GET' && isset($_GET['my'])) {
$user = $_SESSION['oauth_user'] ?? null;
if (!$user) json_error('Not logged in', 401);
$stmt = db()->prepare("
SELECT g.id, g.name, g.streamer_id
FROM rater_groups g
JOIN rater_group_members m ON m.group_id = g.id
WHERE m.user_id = :uid
ORDER BY g.name
");
$stmt->execute([':uid' => $user['id']]);
$groups = $stmt->fetchAll();
json_out($groups);
}
// All remaining endpoints require admin
require_admin();
// ── GET — list all groups with members + linked streamer ────
if ($method === 'GET') {
$groups = db()->query("
SELECT g.id, g.name, g.streamer_id, g.created_at,
s.name AS streamer_name
FROM rater_groups g
LEFT JOIN streamers s ON s.id = g.streamer_id
ORDER BY g.name
")->fetchAll();
foreach ($groups as &$g) {
$stmt = db()->prepare("
SELECT m.id AS member_id, m.role, u.id, u.provider, u.login, u.display_name, u.avatar
FROM rater_group_members m
JOIN users u ON u.id = m.user_id
WHERE m.group_id = :gid
ORDER BY (m.role = 'owner') DESC, m.added_at
");
$stmt->execute([':gid' => $g['id']]);
$g['members'] = $stmt->fetchAll();
$stmt = db()->prepare("SELECT COUNT(*) FROM community_ratings WHERE group_id = :gid");
$stmt->execute([':gid' => $g['id']]);
$g['ratings_count'] = (int)$stmt->fetchColumn();
}
json_out($groups);
}
// ── POST — create group or add member ────────────────────────
if ($method === 'POST') {
if (isset($_GET['members'])) {
$body = body();
$group_id = (int)($body['group_id'] ?? 0);
$login = strtolower(trim($body['login'] ?? ''));
$provider = in_array($body['provider'] ?? '', ['twitch','kick']) ? $body['provider'] : 'twitch';
$role = in_array($body['role'] ?? '', ['owner','rater']) ? $body['role'] : 'rater';
if (!$group_id || empty($login)) json_error('Missing group_id or login');
$stmt = db()->prepare("SELECT id, display_name FROM users WHERE LOWER(login)=:l AND provider=:p AND (banned IS NULL OR banned=false)");
$stmt->execute([':l' => $login, ':p' => $provider]);
$user = $stmt->fetch();
if (!$user) json_error("User '$login' on $provider has not logged in yet or is banned.", 404);
// Only one owner per team — promote, if owner role requested
if ($role === 'owner') {
db()->prepare("UPDATE rater_group_members SET role='rater' WHERE group_id=:gid AND role='owner'")
->execute([':gid' => $group_id]);
}
db()->prepare("
INSERT INTO rater_group_members (group_id, user_id, role)
VALUES (:gid, :uid, :role)
ON CONFLICT (group_id, user_id) DO UPDATE SET role = EXCLUDED.role
")->execute([':gid' => $group_id, ':uid' => $user['id'], ':role' => $role]);
json_out(['ok' => true, 'display_name' => $user['display_name'], 'role' => $role]);
}
$body = body();
$name = trim($body['name'] ?? '');
$streamer_id = !empty($body['streamer_id']) ? (int)$body['streamer_id'] : null;
if (empty($name)) json_error('Missing name');
try {
$stmt = db()->prepare("
INSERT INTO rater_groups (name, streamer_id)
VALUES (:n, :sid)
RETURNING id, name, streamer_id, created_at
");
$stmt->execute([':n' => $name, ':sid' => $streamer_id]);
json_out($stmt->fetch(), 201);
} catch (PDOException $e) {
$msg = $e->getMessage();
if (str_contains($msg, 'rater_groups_name')) json_error('Group name already exists', 409);
if (str_contains($msg, 'idx_rater_groups_streamer')) json_error('This streamer already has a team', 409);
json_error('DB error: ' . $msg, 500);
}
}
// ── PUT — update group (rename, link/unlink streamer) ────────
if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
$body = body();
$sets = [];
$params = [':id' => $id];
if (array_key_exists('name', $body)) {
$name = trim($body['name'] ?? '');
if (empty($name)) json_error('Name cannot be empty');
$sets[] = 'name = :name';
$params[':name'] = $name;
}
if (array_key_exists('streamer_id', $body)) {
$sets[] = 'streamer_id = :sid';
$params[':sid'] = !empty($body['streamer_id']) ? (int)$body['streamer_id'] : null;
}
if (empty($sets)) json_error('Nothing to update');
try {
$stmt = db()->prepare("UPDATE rater_groups SET " . implode(', ', $sets) . " WHERE id = :id");
$stmt->execute($params);
json_out(['ok' => true]);
} catch (PDOException $e) {
$msg = $e->getMessage();
if (str_contains($msg, 'rater_groups_name')) json_error('Group name already exists', 409);
if (str_contains($msg, 'idx_rater_groups_streamer')) json_error('This streamer already has a team', 409);
json_error('DB error', 500);
}
}
// ── DELETE — remove group or member ──────────────────────────
if ($method === 'DELETE') {
if (isset($_GET['member'])) {
$id = (int)($_GET['member'] ?? 0);
if (!$id) json_error('Missing member id');
db()->prepare("DELETE FROM rater_group_members WHERE id=:id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
db()->prepare("DELETE FROM rater_groups WHERE id=:id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.

41
api/settings.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
// ============================================================
// api/settings.php
//
// GET /api/settings — get all settings (public)
// PUT /api/settings — update settings (admin only)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$rows = db()->query("SELECT key, value FROM settings")->fetchAll();
$out = [];
foreach ($rows as $r) {
$out[$r['key']] = $r['value'];
}
json_out($out);
}
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
require_admin();
$body = body();
$allowed = ['auth_enabled', 'auth_twitch_enabled', 'auth_kick_enabled'];
$db = db();
$stmt = $db->prepare("INSERT INTO settings (key, value) VALUES (:k, :v) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value");
foreach ($allowed as $key) {
if (isset($body[$key])) {
$val = $body[$key] === true || $body[$key] === 'true' ? 'true' : 'false';
$stmt->execute([':k' => $key, ':v' => $val]);
}
}
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.

269
api/streamers.php Normal file
View File

@@ -0,0 +1,269 @@
<?php
// ============================================================
// api/streamers.php
//
// GET /api/streamers — public list (evaluated only)
// GET /api/streamers?all=1 — full list (admin or mod)
// POST /api/streamers — suggest a streamer (public)
// POST /api/streamers?admin=1 — add streamer (admin only)
// PUT /api/streamers?id=N — update streamer + rating (admin or mod)
// DELETE /api/streamers?id=N — delete streamer (admin only)
// ============================================================
require_once __DIR__ . '/db.php';
cors();
$method = $_SERVER['REQUEST_METHOD'];
// ------------------------------------------------------------------
// GET — fetch streamers
// ------------------------------------------------------------------
if ($method === 'GET') {
start_session();
$is_admin = !empty($_SESSION['is_admin']);
$is_mod = is_moderator();
// Detect if logged-in OAuth user is a member of any rater team
$is_team_member = false;
if (!$is_admin && !$is_mod && !empty($_SESSION['oauth_user']['id'])) {
$stmt = db()->prepare("SELECT 1 FROM rater_group_members WHERE user_id = :uid LIMIT 1");
$stmt->execute([':uid' => $_SESSION['oauth_user']['id']]);
$is_team_member = (bool)$stmt->fetchColumn();
}
// 'all=1' is admin/mod request for unfiltered list (with notes etc.)
$all = isset($_GET['all']) && ($is_admin || $is_mod);
// Team members must see all streamers (even unevaluated) so they can rate them
$see_unevaluated = $all || $is_team_member;
if ($all) {
$sql = "
SELECT s.*,
r.statistiky, r.grafika, r.alerty, r.vychytavky,
r.nahled, r.nastaveni, r.odlisnost, r.reward, r.notes,
g.id AS team_id, g.name AS team_name
FROM streamers s
LEFT JOIN ratings r ON r.streamer_id = s.id
LEFT JOIN rater_groups g ON g.streamer_id = s.id
ORDER BY s.evaluated DESC, s.name ASC
";
} elseif ($see_unevaluated) {
// Team member: all streamers, but no admin-only fields like notes
$sql = "
SELECT s.id, s.name, s.platform, s.kick_name, s.status,
s.game, s.title, s.evaluated, s.community_locked,
r.statistiky, r.grafika, r.alerty, r.vychytavky,
r.nahled, r.nastaveni, r.odlisnost, r.reward,
g.id AS team_id, g.name AS team_name
FROM streamers s
LEFT JOIN ratings r ON r.streamer_id = s.id
LEFT JOIN rater_groups g ON g.streamer_id = s.id
ORDER BY s.evaluated DESC, s.name ASC
";
} else {
$sql = "
SELECT s.id, s.name, s.platform, s.kick_name, s.status,
s.game, s.title, s.evaluated, s.community_locked,
r.statistiky, r.grafika, r.alerty, r.vychytavky,
r.nahled, r.nastaveni, r.odlisnost, r.reward,
g.id AS team_id, g.name AS team_name
FROM streamers s
INNER JOIN ratings r ON r.streamer_id = s.id
LEFT JOIN rater_groups g ON g.streamer_id = s.id
WHERE s.evaluated = true
ORDER BY (r.statistiky + r.grafika + r.alerty + r.vychytavky +
r.nahled + r.nastaveni + r.odlisnost) DESC
";
}
$rows = db()->query($sql)->fetchAll();
// Reshape: nest ratings under 'r' key to match frontend format
$out = array_map(function($row) {
$s = [
'id' => $row['id'],
'name' => $row['name'],
'platform' => $row['platform'],
'kick' => $row['kick_name'] ?? '',
'status' => $row['status'],
'game' => $row['game'] ?? '',
'title' => $row['title'] ?? '',
'evaluated' => (bool)$row['evaluated'],
'community_locked' => (bool)($row['community_locked'] ?? false),
'added_by' => $row['added_by'] ?? 'admin',
'r' => [
's' => (int)($row['statistiky'] ?? 0),
'g' => (int)($row['grafika'] ?? 0),
'a' => (int)($row['alerty'] ?? 0),
'v' => (int)($row['vychytavky'] ?? 0),
'n' => (int)($row['nahled'] ?? 0),
'ns' => (int)($row['nastaveni'] ?? 0),
'o' => (int)($row['odlisnost'] ?? 0),
],
'reward' => (int)($row['reward'] ?? 0),
'team_id' => isset($row['team_id']) ? (int)$row['team_id'] : null,
'team_name' => $row['team_name'] ?? null,
];
if (isset($row['notes'])) {
$s['notes'] = $row['notes'];
}
return $s;
}, $rows);
json_out($out);
}
// ------------------------------------------------------------------
// POST — add streamer (suggestion from public, or admin add)
// ------------------------------------------------------------------
if ($method === 'POST') {
$body = body();
$is_admin_add = isset($_GET['admin']);
if ($is_admin_add) {
session_start();
if (empty($_SESSION['is_admin'])) json_error('Unauthorized', 401);
}
$name = sanitize_name($body['name'] ?? '');
if (strlen($name) < 2) json_error('Invalid streamer name');
$platform = in_array($body['platform'] ?? '', ['twitch','kick']) ? $body['platform'] : 'twitch';
$kick_name = sanitize_name($body['kick_name'] ?? '');
$submitter = substr(trim($body['submitter'] ?? ''), 0, 60);
$added_by = $is_admin_add ? 'admin' : 'viewer';
try {
$stmt = db()->prepare("
INSERT INTO streamers (name, platform, kick_name, added_by, submitter)
VALUES (:name, :platform, :kick_name, :added_by, :submitter)
RETURNING id
");
$stmt->execute([
':name' => $name,
':platform' => $platform,
':kick_name' => $kick_name,
':added_by' => $added_by,
':submitter' => $submitter,
]);
$row = $stmt->fetch();
json_out(['ok' => true, 'id' => $row['id']], 201);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'unique')) {
json_error('Streamer already exists', 409);
}
json_error('DB error', 500);
}
}
// ------------------------------------------------------------------
// PUT — update streamer + rating (admin or mod)
// ------------------------------------------------------------------
if ($method === 'PUT') {
require_mod();
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
$is_admin = !empty($_SESSION['is_admin']);
$body = body();
// Lock/unlock community ratings — admin only
if (isset($_GET['lock'])) {
if (!$is_admin) json_error('Admin only', 403);
$locked = !empty($body['community_locked']) ? 'true' : 'false';
db()->prepare("UPDATE streamers SET community_locked=:l WHERE id=:id")
->execute([':l' => $locked, ':id' => $id]);
json_out(['ok' => true]);
}
// Clamp rating values 0-10
$clamp = fn($v) => max(0, min(10, (int)$v));
$r = $body['r'] ?? [];
$stats = $clamp($r['s'] ?? 0);
$graf = $clamp($r['g'] ?? 0);
$alert = $clamp($r['a'] ?? 0);
$vych = $clamp($r['v'] ?? 0);
$nahl = $clamp($r['n'] ?? 0);
$nast = $clamp($r['ns'] ?? 0);
$odl = $clamp($r['o'] ?? 0);
$notes = substr(trim($body['notes'] ?? ''), 0, 2000);
$evaluated = ($stats + $graf + $alert + $vych + $nahl + $nast + $odl) > 0;
// Reward — admin can override manually, mods get auto-calculated
$reward = $is_admin
? max(0, min(130, (int)($body['reward'] ?? 0)))
: ($evaluated ? (int)round(30 + (($stats+$graf+$alert+$vych+$nahl+$nast+$odl) / 70) * 100) : 0);
$db = db();
// Moderators can only update ratings, not streamer metadata (status/game/title)
if ($is_admin) {
$kick = sanitize_name($body['kick'] ?? '');
$status = in_array($body['status'] ?? '', ['live','offline']) ? $body['status'] : 'offline';
$game = substr(trim($body['game'] ?? ''), 0, 100);
$title = substr(trim($body['title'] ?? ''), 0, 200);
$db->prepare("
UPDATE streamers
SET kick_name=:kick, status=:status, game=:game, title=:title, evaluated=:evaluated
WHERE id=:id
")->execute([
':kick'=>$kick, ':status'=>$status, ':game'=>$game,
':title'=>$title, ':evaluated'=>$evaluated?'true':'false', ':id'=>$id,
]);
} else {
// Mod: only update evaluated flag
$db->prepare("UPDATE streamers SET evaluated=:evaluated WHERE id=:id")
->execute([':evaluated'=>$evaluated?'true':'false', ':id'=>$id]);
}
// Upsert rating row
$db->prepare("
INSERT INTO ratings
(streamer_id, statistiky, grafika, alerty, vychytavky,
nahled, nastaveni, odlisnost, reward, notes)
VALUES
(:sid, :s, :g, :a, :v, :n, :ns, :o, :reward, :notes)
ON CONFLICT (streamer_id) DO UPDATE SET
statistiky = EXCLUDED.statistiky,
grafika = EXCLUDED.grafika,
alerty = EXCLUDED.alerty,
vychytavky = EXCLUDED.vychytavky,
nahled = EXCLUDED.nahled,
nastaveni = EXCLUDED.nastaveni,
odlisnost = EXCLUDED.odlisnost,
reward = EXCLUDED.reward,
notes = EXCLUDED.notes,
updated_at = NOW()
")->execute([
':sid' => $id,
':s' => $stats,
':g' => $graf,
':a' => $alert,
':v' => $vych,
':n' => $nahl,
':ns' => $nast,
':o' => $odl,
':reward' => $reward,
':notes' => $notes,
]);
json_out(['ok' => true]);
}
// ------------------------------------------------------------------
// DELETE — remove streamer (admin only)
// ------------------------------------------------------------------
if ($method === 'DELETE') {
require_admin();
$id = (int)($_GET['id'] ?? 0);
if (!$id) json_error('Missing id');
db()->prepare("DELETE FROM streamers WHERE id = :id")->execute([':id' => $id]);
json_out(['ok' => true]);
}
json_error('Method not allowed', 405);

Binary file not shown.