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

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.env
.git
.gitignore
README.md
*.zip

44
.env.example Normal file
View File

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
config.php
*.log

32
.htaccess Normal file
View File

@@ -0,0 +1,32 @@
Options -Indexes
# Block direct access to PHP config and internal files
<FilesMatch "^(config\.php|db\.php|schema\.sql)$">
Require all denied
</FilesMatch>
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
<IfModule mod_headers.c>
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"
</IfModule>

38
Dockerfile Normal file
View File

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

144
README.md Normal file
View File

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

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.

43
docker-compose.yml Normal file
View File

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

139
entrypoint.sh Normal file
View File

@@ -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 = '<?php' . PHP_EOL
. 'define(\'DB_HOST\',\'' . (getenv('DB_HOST') ?: 'postgres') . '\');' . PHP_EOL
. 'define(\'DB_PORT\',\'' . (getenv('DB_PORT') ?: '5432') . '\');' . PHP_EOL
. 'define(\'DB_NAME\',\'' . (getenv('DB_NAME') ?: 'streamers_db') . '\');' . PHP_EOL
. 'define(\'DB_USER\',\'' . (getenv('DB_USER') ?: 'streamers_user') . '\');' . PHP_EOL
. 'define(\'DB_PASS\',\'' . addslashes(getenv('DB_PASS') ?: 'changeme') . '\');' . PHP_EOL
. 'define(\'ADMIN_HASH\',\'' . addslashes(\$hash) . '\');' . PHP_EOL
. 'define(\'SESSION_SECRET\',\'' . addslashes(getenv('SESSION_SECRET') ?: 'change_me_32chars') . '\');' . PHP_EOL
. 'define(\'TWITCH_CLIENT_ID\',\'' . (getenv('TWITCH_CLIENT_ID') ?: '') . '\');' . PHP_EOL
. 'define(\'TWITCH_CLIENT_SECRET\',\'' . (getenv('TWITCH_CLIENT_SECRET') ?: '') . '\');' . PHP_EOL
. 'define(\'TWITCH_OAUTH_CLIENT_ID\',\'' . (getenv('TWITCH_OAUTH_CLIENT_ID') ?: getenv('TWITCH_CLIENT_ID') ?: '') . '\');' . PHP_EOL
. 'define(\'TWITCH_OAUTH_CLIENT_SECRET\',\'' . (getenv('TWITCH_OAUTH_CLIENT_SECRET') ?: getenv('TWITCH_CLIENT_SECRET') ?: '') . '\');' . PHP_EOL
. 'define(\'TWITCH_OAUTH_REDIRECT_URI\',\'' . (getenv('TWITCH_OAUTH_REDIRECT_URI') ?: 'https://rating.naughtybulldogs.eu/api/oauth?provider=twitch') . '\');' . PHP_EOL
. 'define(\'KICK_OAUTH_CLIENT_ID\',\'' . (getenv('KICK_OAUTH_CLIENT_ID') ?: '') . '\');' . PHP_EOL
. 'define(\'KICK_OAUTH_CLIENT_SECRET\',\'' . (getenv('KICK_OAUTH_CLIENT_SECRET') ?: '') . '\');' . PHP_EOL
. 'define(\'KICK_OAUTH_REDIRECT_URI\',\'' . (getenv('KICK_OAUTH_REDIRECT_URI') ?: 'https://rating.naughtybulldogs.eu/api/oauth?provider=kick') . '\');' . PHP_EOL
. 'define(\'KICK_API_BASE\',\'https://kick.com/api/v2/channels\');' . PHP_EOL
. 'define(\'LIVE_CACHE_TTL\',' . (int)(getenv('LIVE_CACHE_TTL') ?: 120) . ');' . PHP_EOL
. 'define(\'ALLOWED_ORIGIN\',\'' . (getenv('ALLOWED_ORIGIN') ?: '*') . '\');' . PHP_EOL;
file_put_contents('/var/www/html/config.php', \$config);
echo '[entrypoint] config.php written' . PHP_EOL;
"
echo "[entrypoint] Waiting for PostgreSQL at ${DB_HOST:-postgres}:${DB_PORT:-5432}..."
for i in $(seq 1 30); do
if php -r "
try {
new PDO('pgsql:host=${DB_HOST:-postgres};port=${DB_PORT:-5432};dbname=${DB_NAME:-streamers_db}','${DB_USER:-streamers_user}','${DB_PASS:-changeme}');
exit(0);
} catch (Exception \$e) { exit(1); }
" 2>/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'
<?php
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', getenv('DB_HOST') ?: 'postgres', getenv('DB_PORT') ?: '5432', getenv('DB_NAME') ?: 'streamers_db');
$pdo = new PDO($dsn, getenv('DB_USER') ?: 'streamers_user', getenv('DB_PASS') ?: 'changeme');
$pdo->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

1538
public/index.html Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

206
schema.sql Normal file
View File

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