first commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.zip
|
||||
44
.env.example
Normal file
44
.env.example
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
config.php
|
||||
*.log
|
||||
32
.htaccess
Normal file
32
.htaccess
Normal 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
38
Dockerfile
Normal 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
144
README.md
Normal 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
45
api/auth.php
Normal 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);
|
||||
BIN
api/auth.php:Zone.Identifier
Normal file
BIN
api/auth.php:Zone.Identifier
Normal file
Binary file not shown.
122
api/comments.php
Normal file
122
api/comments.php
Normal 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);
|
||||
BIN
api/comments.php:Zone.Identifier
Normal file
BIN
api/comments.php:Zone.Identifier
Normal file
Binary file not shown.
228
api/community_ratings.php
Normal file
228
api/community_ratings.php
Normal 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);
|
||||
BIN
api/community_ratings.php:Zone.Identifier
Normal file
BIN
api/community_ratings.php:Zone.Identifier
Normal file
Binary file not shown.
128
api/db.php
Normal file
128
api/db.php
Normal 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
BIN
api/db.php:Zone.Identifier
Normal file
Binary file not shown.
225
api/live.php
Normal file
225
api/live.php
Normal 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;
|
||||
}
|
||||
BIN
api/live.php:Zone.Identifier
Normal file
BIN
api/live.php:Zone.Identifier
Normal file
Binary file not shown.
106
api/moderators.php
Normal file
106
api/moderators.php
Normal 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);
|
||||
BIN
api/moderators.php:Zone.Identifier
Normal file
BIN
api/moderators.php:Zone.Identifier
Normal file
Binary file not shown.
276
api/oauth.php
Normal file
276
api/oauth.php
Normal 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);
|
||||
}
|
||||
BIN
api/oauth.php:Zone.Identifier
Normal file
BIN
api/oauth.php:Zone.Identifier
Normal file
Binary file not shown.
172
api/rater_groups.php
Normal file
172
api/rater_groups.php
Normal 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);
|
||||
BIN
api/rater_groups.php:Zone.Identifier
Normal file
BIN
api/rater_groups.php:Zone.Identifier
Normal file
Binary file not shown.
41
api/settings.php
Normal file
41
api/settings.php
Normal 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);
|
||||
BIN
api/settings.php:Zone.Identifier
Normal file
BIN
api/settings.php:Zone.Identifier
Normal file
Binary file not shown.
269
api/streamers.php
Normal file
269
api/streamers.php
Normal 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);
|
||||
BIN
api/streamers.php:Zone.Identifier
Normal file
BIN
api/streamers.php:Zone.Identifier
Normal file
Binary file not shown.
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal 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
139
entrypoint.sh
Normal 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
1538
public/index.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/index.html:Zone.Identifier
Normal file
BIN
public/index.html:Zone.Identifier
Normal file
Binary file not shown.
206
schema.sql
Normal file
206
schema.sql
Normal 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;
|
||||
Reference in New Issue
Block a user