commit 153c83f7fa50a04d7827c2247202a830a1879d56 Author: Vlastislav Svatek Date: Sun Apr 26 02:23:11 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..602b14e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +.git +.gitignore +README.md +*.zip diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fa7bad --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# env_file načítá hodnoty RAW — $ znaky v ADMIN_HASH NEESCAPUJ, piš normálně +# Vygeneruj hash: docker compose exec app php -r "echo password_hash('heslo', PASSWORD_BCRYPT) . PHP_EOL;" + +APP_PORT=8080 + +DB_NAME=streamers_db +DB_USER=streamers_user +DB_PASS=change_me_strong_db_password + +# Plaintext heslo — hash se vygeneruje automaticky při startu +ADMIN_PASSWORD=tve_admin_heslo +# NEBO vlož hash přímo ($ neescapuj): +# ADMIN_HASH=$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +SESSION_SECRET=change_me_to_something_very_random_here + +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= + +LIVE_CACHE_TTL=120 +ALLOWED_ORIGIN=* + + +# Random session secret (min 32 chars) +SESSION_SECRET=change_me_to_something_very_random_here + +# Twitch API — https://dev.twitch.tv/console/apps +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= +# OAuth redirect URI — must match exactly what's registered in Twitch dev console +TWITCH_OAUTH_REDIRECT_URI=https://rating.naughtybulldogs.eu/api/oauth?provider=twitch + +# Kick OAuth — PKCE flow +# Registrace: https://kick.com/settings/developer +# Redirect URL: https://rating.naughtybulldogs.eu/api/oauth?provider=kick +KICK_OAUTH_CLIENT_ID= +KICK_OAUTH_CLIENT_SECRET= +KICK_OAUTH_REDIRECT_URI=https://rating.naughtybulldogs.eu/api/oauth?provider=kick + +# Live status cache TTL in seconds +LIVE_CACHE_TTL=120 + +# Your public domain for CORS (use * for local dev) +ALLOWED_ORIGIN=* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a75ef9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +config.php +*.log diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..3c05c2f --- /dev/null +++ b/.htaccess @@ -0,0 +1,32 @@ +Options -Indexes + +# Block direct access to PHP config and internal files + + Require all denied + + +RewriteEngine On +RewriteBase / + +# Skip rewrite if file physically exists +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# API routing +RewriteRule ^api/auth/?$ api/auth.php [QSA,L] +RewriteRule ^api/streamers/?$ api/streamers.php [QSA,L] +RewriteRule ^api/live/?$ api/live.php [QSA,L] +RewriteRule ^api/comments/?$ api/comments.php [QSA,L] +RewriteRule ^api/oauth/?$ api/oauth.php [QSA,L] +RewriteRule ^api/settings/?$ api/settings.php [QSA,L] +RewriteRule ^api/moderators/?$ api/moderators.php [QSA,L] +RewriteRule ^api/rater_groups/?$ api/rater_groups.php [QSA,L] +RewriteRule ^api/community_ratings/?$ api/community_ratings.php [QSA,L] + +# Security headers + + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "ALLOW-FROM https://nb.garoshi.eu https://naughtybulldogs.eu" + Header always set Content-Security-Policy "frame-ancestors 'self' https://nb.garoshi.eu https://naughtybulldogs.eu" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c82d1a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM php:8.3-apache + +# Install PostgreSQL PDO extension + curl +RUN apt-get update && apt-get install -y libpq-dev \ + && docker-php-ext-install pdo pdo_pgsql \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Enable Apache mod_rewrite (needed for .htaccess routing) +RUN a2enmod rewrite headers + +# Allow .htaccess overrides in webroot +RUN sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf + +# Copy app files +COPY . /var/www/html/ + +# Move public/index.html to webroot root, keep api/ and config accessible +# Directory layout inside container: +# /var/www/html/index.html ← frontend +# /var/www/html/api/ ← PHP endpoints +# /var/www/html/config.php ← credentials (injected via env) +# /var/www/html/schema.sql ← not served (blocked by .htaccess) +RUN cp /var/www/html/public/index.html /var/www/html/index.html \ + && rm -rf /var/www/html/public + +# Config is generated at container start from env vars (see entrypoint.sh) +# Remove the static config.php — it will be written by entrypoint +RUN rm -f /var/www/html/config.php + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Writable temp dir for live status cache + twitch token cache +RUN mkdir -p /tmp/snb_cache && chown www-data:www-data /tmp/snb_cache + +EXPOSE 80 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ded2d0 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# NaughtyBulldogs — Streamer Rating App + +## Rychlý start (Docker) + +```bash +# 1. Zkopíruj a edituj .env +cp .env.example .env + +# 2. Vygeneruj bcrypt hash admin hesla +docker compose run --rm app php -r "echo password_hash('tve_heslo', PASSWORD_BCRYPT);" +# Zkopíruj výstup do .env jako ADMIN_HASH=... + +# 3. Spusť +docker compose up -d + +# App běží na http://localhost:8080 +``` + +Při prvním startu entrypoint automaticky: +- Počká na PostgreSQL (healthcheck) +- Vytvoří tabulky a naseeduje všechny streamery ze schema.sql +- Spustí Apache + +```bash +# Logy +docker compose logs -f app + +# Restart +docker compose restart app + +# Stop + smazání dat +docker compose down -v +``` + +--- + +## Stack +- **Frontend**: Vanilla JS (single HTML file, no build step) +- **Backend**: PHP 8.1+ +- **Database**: PostgreSQL 14+ + +## Struktura souborů + +``` +/ +├── public/ +│ └── index.html ← celý frontend +├── api/ +│ ├── db.php ← PDO připojení + helpers (nepřístupné zvenčí) +│ ├── auth.php ← login / logout / session check +│ ├── streamers.php ← CRUD streamerů + hodnocení +│ └── live.php ← live refresh (Twitch + Kick API) +├── config.php ← credentials (NIKDY do gitu!) +├── schema.sql ← PostgreSQL schéma + seed data +├── .htaccess ← routing + security headers +└── .gitignore +``` + +## Instalace + +### 1. PostgreSQL databáze + +```bash +# Vytvoř databázi a uživatele +psql -U postgres +CREATE DATABASE streamers_db; +CREATE USER streamers_user WITH ENCRYPTED PASSWORD 'silne_heslo'; +GRANT ALL PRIVILEGES ON DATABASE streamers_db TO streamers_user; +\q + +# Aplikuj schema +psql -U streamers_user -d streamers_db -f schema.sql +``` + +### 2. config.php + +```bash +cp config.php config.php +# Edituj hodnoty: +# - DB_HOST, DB_NAME, DB_USER, DB_PASS +# - ADMIN_HASH — vygeneruj: php -r "echo password_hash('tve_heslo', PASSWORD_BCRYPT);" +# - SESSION_SECRET — náhodný string min 32 znaků +# - TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET (z https://dev.twitch.tv/console/apps) +# - ALLOWED_ORIGIN — tvá doména +``` + +### 3. Twitch API credentials + +1. Jdi na https://dev.twitch.tv/console/apps +2. Vytvoř novou aplikaci +3. OAuth Redirect URL: `https://tvadomena.cz` +4. Zkopíruj **Client ID** a **Client Secret** do `config.php` + +### 4. Nahrání na server + +```bash +# Nahraj všechny soubory kromě config.php (ten edituj přímo na serveru) +rsync -av --exclude='config.php' ./ user@server:/var/www/streamers/ + +# Na serveru edituj config.php +ssh user@server +nano /var/www/streamers/config.php +``` + +### 5. PHP session nastavení + +Ujisti se, že server má `session.cookie_httponly = On` a `session.cookie_secure = On` (pro HTTPS). + +## API endpoints + +| Method | URL | Auth | Popis | +|--------|-----|------|-------| +| GET | `/api/streamers` | — | Veřejný seznam (jen ohodnocení) | +| GET | `/api/streamers?all=1` | Admin | Všichni streamers | +| POST | `/api/streamers` | — | Navrhnout streamera (divák) | +| POST | `/api/streamers?admin=1` | Admin | Přidat streamera | +| PUT | `/api/streamers?id=N` | Admin | Uložit hodnocení | +| DELETE | `/api/streamers?id=N` | Admin | Smazat streamera | +| GET | `/api/auth?action=check` | — | Zkontrolovat session | +| POST | `/api/auth?action=login` | — | Přihlásit admin | +| POST | `/api/auth?action=logout` | Admin | Odhlásit | +| GET | `/api/live` | Admin | Refresh live statusů | + +## Live refresh + +- Twitch: Helix API, batch po 100 streamerech — Přesný, spolehlivý +- Kick: veřejný endpoint `kick.com/api/v2/channels/{name}` — funguje bez auth, ale může se změnit +- Cache: výsledky jsou cachované 120s (nastavitelné v `config.php` jako `LIVE_CACHE_TTL`) +- Refresh se spouští ručně tlačítkem v admin panelu (nebo cron job) + +### Volitelný cron pro automatický refresh + +```bash +# Každé 3 minuty refreshuj live statusy (potřebuje admin session — doporučeno CLI script) +*/3 * * * * curl -s -b "PHPSESSID=..." https://tvadomena.cz/api/live > /dev/null +``` + +## .gitignore + +``` +config.php +/tmp/ +*.log +``` diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..0ba63f6 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,45 @@ + !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); diff --git a/api/auth.php:Zone.Identifier b/api/auth.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/auth.php:Zone.Identifier differ diff --git a/api/comments.php b/api/comments.php new file mode 100644 index 0000000..fbc7929 --- /dev/null +++ b/api/comments.php @@ -0,0 +1,122 @@ +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); diff --git a/api/comments.php:Zone.Identifier b/api/comments.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/comments.php:Zone.Identifier differ diff --git a/api/community_ratings.php b/api/community_ratings.php new file mode 100644 index 0000000..e4b34c7 --- /dev/null +++ b/api/community_ratings.php @@ -0,0 +1,228 @@ +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); diff --git a/api/community_ratings.php:Zone.Identifier b/api/community_ratings.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/community_ratings.php:Zone.Identifier differ diff --git a/api/db.php b/api/db.php new file mode 100644 index 0000000..028f501 --- /dev/null +++ b/api/db.php @@ -0,0 +1,128 @@ + '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))); +} diff --git a/api/db.php:Zone.Identifier b/api/db.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/db.php:Zone.Identifier differ diff --git a/api/live.php b/api/live.php new file mode 100644 index 0000000..b1bedbe --- /dev/null +++ b/api/live.php @@ -0,0 +1,225 @@ +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; +} diff --git a/api/live.php:Zone.Identifier b/api/live.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/live.php:Zone.Identifier differ diff --git a/api/moderators.php b/api/moderators.php new file mode 100644 index 0000000..5afadb7 --- /dev/null +++ b/api/moderators.php @@ -0,0 +1,106 @@ +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); diff --git a/api/moderators.php:Zone.Identifier b/api/moderators.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/moderators.php:Zone.Identifier differ diff --git a/api/oauth.php b/api/oauth.php new file mode 100644 index 0000000..f6e6f56 --- /dev/null +++ b/api/oauth.php @@ -0,0 +1,276 @@ +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); +} diff --git a/api/oauth.php:Zone.Identifier b/api/oauth.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/oauth.php:Zone.Identifier differ diff --git a/api/rater_groups.php b/api/rater_groups.php new file mode 100644 index 0000000..e4c8db1 --- /dev/null +++ b/api/rater_groups.php @@ -0,0 +1,172 @@ +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); diff --git a/api/rater_groups.php:Zone.Identifier b/api/rater_groups.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/rater_groups.php:Zone.Identifier differ diff --git a/api/settings.php b/api/settings.php new file mode 100644 index 0000000..fc1d7d7 --- /dev/null +++ b/api/settings.php @@ -0,0 +1,41 @@ +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); diff --git a/api/settings.php:Zone.Identifier b/api/settings.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/settings.php:Zone.Identifier differ diff --git a/api/streamers.php b/api/streamers.php new file mode 100644 index 0000000..8ef1bed --- /dev/null +++ b/api/streamers.php @@ -0,0 +1,269 @@ +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); diff --git a/api/streamers.php:Zone.Identifier b/api/streamers.php:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/api/streamers.php:Zone.Identifier differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..63caa88 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + + app: + build: . + image: naughtybulldogs-rating:latest + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:80" + env_file: + - .env + environment: + DB_HOST: postgres + depends_on: + postgres: + condition: service_healthy + volumes: + - cache:/tmp/snb_cache + + postgres: + image: postgres:16-alpine + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${DB_NAME:-streamers_db} + POSTGRES_USER: ${DB_USER:-streamers_user} + POSTGRES_PASSWORD: ${DB_PASS:-changeme} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-streamers_user} -d ${DB_NAME:-streamers_db}"] + interval: 5s + timeout: 5s + retries: 10 + +# Poznámky k nasazení: +# - Standalone: APP_PORT=8080 v .env, přístup přes http://host:8080 +# - Synology Web Station reverse proxy: APP_PORT=8080, reverse proxy na localhost:8080 +# - Traefik / jiný proxy: stejně APP_PORT nastav, proxy směruj na daný port + +volumes: + pgdata: + cache: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..66f6b8a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,139 @@ +#!/bin/bash +set -e + +# Generate config.php from env vars using PHP (avoids bash dollar-sign issues) +php -r " + \$hash = getenv('ADMIN_HASH'); + if (empty(\$hash)) { + \$pw = getenv('ADMIN_PASSWORD') ?: 'changeme'; + \$hash = password_hash(\$pw, PASSWORD_BCRYPT); + echo '[entrypoint] Generated hash from ADMIN_PASSWORD' . PHP_EOL; + } else { + echo '[entrypoint] Using ADMIN_HASH from environment' . PHP_EOL; + } + \$config = '/dev/null; then + echo "[entrypoint] PostgreSQL ready." + break + fi + echo "[entrypoint] Waiting... ($i/30)" + sleep 1 +done + +# Write migration script to a temp file to avoid bash/PHP quoting conflicts +cat > /tmp/migrate.php << 'PHPEOF' +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +echo "[entrypoint] Applying schema...\n"; +$pdo->exec(file_get_contents('/var/www/html/schema.sql')); +echo "[entrypoint] Schema OK.\n"; + +// Settings defaults +$pdo->exec("INSERT INTO settings (key, value) VALUES ('auth_enabled','true') ON CONFLICT (key) DO NOTHING"); +$pdo->exec("INSERT INTO settings (key, value) VALUES ('auth_twitch_enabled','true') ON CONFLICT (key) DO NOTHING"); +$pdo->exec("INSERT INTO settings (key, value) VALUES ('auth_kick_enabled','true') ON CONFLICT (key) DO NOTHING"); +echo "[entrypoint] Settings defaults OK.\n"; + +// Migrations +$pdo->exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE"); +echo "[entrypoint] users.banned OK.\n"; + +$pdo->exec("CREATE TABLE IF NOT EXISTS moderators ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id) +)"); +echo "[entrypoint] moderators OK.\n"; + +$pdo->exec("CREATE TABLE IF NOT EXISTS oauth_state ( + state TEXT PRIMARY KEY, + provider TEXT NOT NULL, + verifier TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)"); +echo "[entrypoint] oauth_state OK.\n"; + +$pdo->exec("ALTER TABLE comments ADD COLUMN IF NOT EXISTS user_id INT REFERENCES users(id) ON DELETE SET NULL"); +echo "[entrypoint] comments.user_id OK.\n"; + +// Community ratings tables +$pdo->exec("CREATE TABLE IF NOT EXISTS rater_groups ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)"); +$pdo->exec("CREATE TABLE IF NOT EXISTS rater_group_members ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES rater_groups(id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (group_id, user_id) +)"); +$pdo->exec("CREATE TABLE IF NOT EXISTS community_ratings ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES rater_groups(id) ON DELETE CASCADE, + streamer_id INT NOT NULL REFERENCES streamers(id) ON DELETE CASCADE, + statistiky SMALLINT NOT NULL DEFAULT 0, + grafika SMALLINT NOT NULL DEFAULT 0, + alerty SMALLINT NOT NULL DEFAULT 0, + vychytavky SMALLINT NOT NULL DEFAULT 0, + nahled SMALLINT NOT NULL DEFAULT 0, + nastaveni SMALLINT NOT NULL DEFAULT 0, + odlisnost SMALLINT NOT NULL DEFAULT 0, + notes TEXT DEFAULT '', + updated_by INT REFERENCES users(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (group_id, streamer_id) +)"); +$pdo->exec("ALTER TABLE streamers ADD COLUMN IF NOT EXISTS community_locked BOOLEAN NOT NULL DEFAULT FALSE"); +$pdo->exec("CREATE INDEX IF NOT EXISTS idx_community_ratings_streamer ON community_ratings(streamer_id)"); +$pdo->exec("CREATE INDEX IF NOT EXISTS idx_rater_group_members_user ON rater_group_members(user_id)"); +// Link a team to a specific streamer (optional — null = generic team) +$pdo->exec("ALTER TABLE rater_groups ADD COLUMN IF NOT EXISTS streamer_id INT REFERENCES streamers(id) ON DELETE SET NULL"); +$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_rater_groups_streamer ON rater_groups(streamer_id) WHERE streamer_id IS NOT NULL"); +// Role within a rater team: 'owner' = the streamer themselves, 'rater' = team moderator/rater +$pdo->exec("ALTER TABLE rater_group_members ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'rater'"); +echo "[entrypoint] community_ratings tables OK.\n"; + +$fixed = $pdo->exec("UPDATE streamers SET kick_name = name WHERE platform = 'kick' AND (kick_name IS NULL OR kick_name = '')"); +if ($fixed > 0) echo "[entrypoint] Fixed kick_name for {$fixed} streamer(s).\n"; +PHPEOF + +php /tmp/migrate.php + +echo "[entrypoint] Starting Apache..." +exec apache2-foreground diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..bf50641 --- /dev/null +++ b/public/index.html @@ -0,0 +1,1538 @@ + + + + + +NaughtyBulldogs — Hodnocení streamerů + + + +
+ + + diff --git a/public/index.html:Zone.Identifier b/public/index.html:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/public/index.html:Zone.Identifier differ diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..82406a3 --- /dev/null +++ b/schema.sql @@ -0,0 +1,206 @@ +-- NaughtyBulldogs Streamer Rating DB +-- PostgreSQL schema + +CREATE TABLE IF NOT EXISTS streamers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT 'twitch', + kick_name TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'offline', + game TEXT DEFAULT '', + title TEXT DEFAULT '', + added_by TEXT NOT NULL DEFAULT 'admin', -- 'admin' or 'viewer' + submitter TEXT DEFAULT '', -- viewer's nickname if added_by='viewer' + evaluated BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ratings ( + id SERIAL PRIMARY KEY, + streamer_id INT NOT NULL REFERENCES streamers(id) ON DELETE CASCADE, + statistiky SMALLINT NOT NULL DEFAULT 0 CHECK (statistiky BETWEEN 0 AND 10), + grafika SMALLINT NOT NULL DEFAULT 0 CHECK (grafika BETWEEN 0 AND 10), + alerty SMALLINT NOT NULL DEFAULT 0 CHECK (alerty BETWEEN 0 AND 10), + vychytavky SMALLINT NOT NULL DEFAULT 0 CHECK (vychytavky BETWEEN 0 AND 10), + nahled SMALLINT NOT NULL DEFAULT 0 CHECK (nahled BETWEEN 0 AND 10), + nastaveni SMALLINT NOT NULL DEFAULT 0 CHECK (nastaveni BETWEEN 0 AND 10), + odlisnost SMALLINT NOT NULL DEFAULT 0 CHECK (odlisnost BETWEEN 0 AND 10), + reward SMALLINT NOT NULL DEFAULT 0 CHECK (reward BETWEEN 0 AND 130), + notes TEXT DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (streamer_id) +); + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + provider TEXT NOT NULL, -- 'twitch' + provider_id TEXT NOT NULL, -- Twitch user ID + login TEXT NOT NULL, -- username + display_name TEXT NOT NULL, + avatar TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (provider, provider_id) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +-- Default settings +INSERT INTO settings (key, value) VALUES + ('auth_enabled', 'true'), + ('auth_twitch_enabled', 'true'), + ('auth_kick_enabled', 'true') +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + streamer_id INT NOT NULL REFERENCES streamers(id) ON DELETE CASCADE, + user_id INT REFERENCES users(id) ON DELETE SET NULL, + author TEXT NOT NULL DEFAULT 'Anonym', + body TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_comments_streamer ON comments(streamer_id); + +CREATE TABLE IF NOT EXISTS moderators ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id) +); + +CREATE TABLE IF NOT EXISTS oauth_state ( + state TEXT PRIMARY KEY, + provider TEXT NOT NULL, + verifier TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- Auto-cleanup states older than 10 minutes +CREATE INDEX IF NOT EXISTS idx_oauth_state_created ON oauth_state(created_at); + +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at = NOW(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER trg_streamers_updated + BEFORE UPDATE ON streamers + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE OR REPLACE TRIGGER trg_ratings_updated + BEFORE UPDATE ON ratings + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Seed data from CSV +INSERT INTO streamers (name, platform, status, game, title, evaluated) VALUES +('pigihonalejvarna', 'twitch', 'offline', '', '', false), +('mrcarwen', 'twitch', 'offline', '', '', false), +('bobsixkillercz', 'twitch', 'offline', '', '', false), +('evrondisek', 'twitch', 'offline', '', '', false), +('bigmajkycz', 'twitch', 'offline', '', '', false), +('jsemsadlo', 'twitch', 'offline', '', '', false), +('valkojr', 'twitch', 'offline', '', '', false), +('gathersk', 'twitch', 'offline', '', '', false), +('knefi_', 'twitch', 'offline', '', '', false), +('housepartycz14', 'twitch', 'offline', '', '', false), +('NaughtyBulldogsCZ','twitch','offline', '', '', false), +('mistr13', 'twitch', 'offline', '', '', false), +('annniecz', 'twitch', 'offline', '', '', false), +('Haruhi4chan', 'twitch', 'offline', '', '', false), +('bordaclands', 'twitch', 'offline', '', '', false), +('theskotos', 'twitch', 'offline', '', '', false), +('cicero_mt', 'twitch', 'offline', '', '', false), +('tessa_terry', 'twitch', 'offline', '', '', false), +('vtmod', 'twitch', 'offline', '', '', false), +('unholy666cz', 'twitch', 'offline', '', '', false), +('sh0rtyttv', 'twitch', 'offline', '', '', false), +('2002rebelka', 'twitch', 'offline', '', '', false), +('anetaamelie', 'twitch', 'offline', '', '', false), +('pajaaa18', 'twitch', 'offline', '', '', false), +('dedajede', 'twitch', 'offline', '', '', false), +('cliffboothcz', 'twitch', 'offline', '', '', false), +('aaleky', 'twitch', 'offline', '', '', false), +('jarvisgamingtv', 'twitch', 'offline', '', '', false), +('falazury', 'twitch', 'offline', '', '', false), +('tenfila', 'twitch', 'offline', '', '', false), +('mistrloutkar', 'twitch', 'offline', '', '', false), +('czechpatres', 'twitch', 'offline', '', '', false), +('barbee02', 'twitch', 'offline', '', '', false), +('nej_gamer', 'twitch', 'offline', '', '', false), +('d_o_n_alien', 'twitch', 'offline', '', '', false), +('entery99', 'twitch', 'offline', '', '', false), +('lukyss__', 'twitch', 'offline', '', '', false), +('mumu011', 'twitch', 'offline', '', '', false), +('jezisnazaretskyy','twitch', 'offline', '', '', false), +('crackfang', 'twitch', 'offline', '', '', false), +('julesteam', 'twitch', 'offline', '', '', false), +('tommyproductions', 'twitch','offline', '', '', false), +('vandalczz', 'twitch', 'offline', '', '', false), +('jirilaska', 'twitch', 'offline', '', '', false), +('xazzli', 'twitch', 'offline', '', '', false), +('kubape_', 'twitch', 'offline', '', '', false), +('fulopka', 'twitch', 'offline', '', '', false), +('mentiiix', 'twitch', 'offline', '', '', false), +('kristyyyyyyyyyyyy','twitch','offline', '', '', false), +('honzajcz', 'twitch', 'offline', '', '', false), +('vercrowcz', 'twitch', 'offline', '', '', false), +('akcelcz', 'twitch', 'offline', '', '', false), +('zeldok_', 'twitch', 'offline', '', '', false), +('drok_alar', 'twitch', 'offline', '', '', false), +('heimdallsvk', 'twitch', 'offline', '', '', false), +('darkvalkyra', 'twitch', 'offline', '', '', false), +('yakobcz', 'twitch', 'offline', '', '', false), +('satankattv', 'twitch', 'offline', '', '', false), +('paciinka', 'twitch', 'offline', '', '', false), +('nanami_blue_', 'twitch', 'offline', '', '', false), +('qwerydesign', 'twitch', 'offline', '', '', false), +('xxmonsterkaxx', 'twitch', 'offline', '', '', false), +('cleonatwitchi', 'twitch', 'offline', '', '', false), +('yukiidemon35', 'twitch', 'offline', '', '', false), +('denda_dennym', 'twitch', 'offline', '', '', false), +('korpo86', 'twitch', 'offline', '', '', false), +('m3rllin99', 'twitch', 'offline', '', '', false), +('hexor_fps', 'twitch', 'offline', '', '', false), +('polcarka1', 'twitch', 'offline', '', '', false), +('relic3dx', 'twitch', 'offline', '', '', false), +('poseidonpolabi', 'twitch', 'offline', '', '', false), +('podogor', 'twitch', 'offline', '', '', false), +('hermicze', 'twitch', 'offline', '', '', false), +('aifumi', 'twitch', 'offline', '', '', false), +('metl0sh', 'twitch', 'offline', '', '', false), +('kiycu', 'twitch', 'offline', '', '', false), +('johnydr4gon', 'twitch', 'offline', '', '', false), +('darkmortyr', 'twitch', 'offline', '', '', false), +('enbeast', 'twitch', 'offline', '', '', false), +('shery_len', 'twitch', 'offline', '', '', false), +('nejakyluke', 'twitch', 'offline', '', '', false), +('ulnear', 'twitch', 'offline', '', '', false), +('matyanes', 'twitch', 'offline', '', '', false), +('realelita15', 'twitch', 'offline', '', '', false), +('different_culture','twitch','offline', '', '', false), +('kapitan_vojta', 'twitch', 'offline', '', '', false), +('velkygouda', 'twitch', 'offline', '', '', false), +('17stanley_', 'twitch', 'offline', '', '', false), +('boudasek', 'twitch', 'offline', '', '', false), +('pajik_002', 'twitch', 'offline', '', '', false), +('gojirasaurr', 'twitch', 'offline', '', '', false), +('dattmark', 'twitch', 'offline', '', '', false), +('topkekstreaming', 'twitch','offline', '', '', false), +('vikita0_0', 'twitch', 'offline', '', '', false), +('tukan_cze', 'twitch', 'offline', '', '', false), +('seniseeq_', 'twitch', 'offline', '', '', false), +('mekaaja', 'twitch', 'offline', '', '', false), +('therasiaquinn', 'twitch', 'offline', '', '', false), +('moralnipodpora', 'twitch', 'offline', '', '', false), +('yanekcze', 'twitch', 'offline', '', '', false), +('ma_ty_x', 'twitch', 'offline', '', '', false), +('foxprague', 'twitch', 'offline', '', '', false), +('Aimonka', 'twitch', 'offline', '', '', false) +ON CONFLICT (name) DO NOTHING;