390 lines
11 KiB
PHP
390 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
|
|
const DATA_DIR = __DIR__ . '/data';
|
|
const UUID_PATTERN = '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/';
|
|
|
|
if (!is_dir(DATA_DIR)) {
|
|
mkdir(DATA_DIR, 0775, true);
|
|
}
|
|
|
|
function fail(int $status, string $message): void {
|
|
http_response_code($status);
|
|
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function clean_user(string $value): string {
|
|
$trimmed = trim($value);
|
|
if ($trimmed === '' || mb_strlen($trimmed) > 30) {
|
|
fail(400, 'Invalid user');
|
|
}
|
|
return $trimmed;
|
|
}
|
|
|
|
function clean_movie(string $value): string {
|
|
$trimmed = trim($value);
|
|
// Strip leading trophy emoji if present (e.g., "🏆 Matrix" -> "Matrix")
|
|
$trimmed = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $trimmed);
|
|
if ($trimmed === '' || mb_strlen($trimmed) > 60) {
|
|
fail(400, 'Invalid movie title');
|
|
}
|
|
return $trimmed;
|
|
}
|
|
|
|
function normalize_users(array $users): array {
|
|
$normalized = [];
|
|
foreach ($users as $user) {
|
|
if (!is_string($user)) {
|
|
continue;
|
|
}
|
|
$name = trim($user);
|
|
if ($name === '' || mb_strlen($name) > 30) {
|
|
continue;
|
|
}
|
|
if (!in_array($name, $normalized, true)) {
|
|
$normalized[] = $name;
|
|
}
|
|
}
|
|
return $normalized;
|
|
}
|
|
|
|
function normalize_done_users(array $doneUsers, array $users): array {
|
|
$normalized = [];
|
|
foreach ($doneUsers as $name) {
|
|
if (!is_string($name)) {
|
|
continue;
|
|
}
|
|
if (!in_array($name, $users, true)) {
|
|
continue;
|
|
}
|
|
if (!in_array($name, $normalized, true)) {
|
|
$normalized[] = $name;
|
|
}
|
|
}
|
|
return $normalized;
|
|
}
|
|
|
|
function all_users_done(array $users, array $doneUsers): bool {
|
|
if (count($users) === 0) {
|
|
return false;
|
|
}
|
|
foreach ($users as $name) {
|
|
if (!in_array($name, $doneUsers, true)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function load_state(string $uuid): array {
|
|
$path = DATA_DIR . '/' . $uuid . '.json';
|
|
if (!file_exists($path)) {
|
|
return [
|
|
'uuid' => $uuid,
|
|
'phase' => 1,
|
|
'setupDone' => false,
|
|
'users' => [],
|
|
'doneUsersPhase1' => [],
|
|
'doneUsersPhase2' => [],
|
|
'movies' => [],
|
|
'votes' => new stdClass(),
|
|
'updatedAt' => gmdate('c'),
|
|
'createdAt' => gmdate('c'),
|
|
];
|
|
}
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false) {
|
|
fail(500, 'Could not read state');
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
fail(500, 'Corrupt state file');
|
|
}
|
|
$decoded['setupDone'] = (bool)($decoded['setupDone'] ?? false);
|
|
$decoded['users'] = normalize_users(is_array($decoded['users'] ?? null) ? $decoded['users'] : []);
|
|
$decoded['doneUsersPhase1'] = normalize_done_users(is_array($decoded['doneUsersPhase1'] ?? null) ? $decoded['doneUsersPhase1'] : [], $decoded['users']);
|
|
$decoded['doneUsersPhase2'] = normalize_done_users(is_array($decoded['doneUsersPhase2'] ?? null) ? $decoded['doneUsersPhase2'] : [], $decoded['users']);
|
|
$decoded['movies'] = is_array($decoded['movies'] ?? null) ? $decoded['movies'] : [];
|
|
$decoded['votes'] = is_array($decoded['votes'] ?? null) ? $decoded['votes'] : [];
|
|
$decoded['phase'] = (int)($decoded['phase'] ?? 1);
|
|
if ($decoded['phase'] !== 1 && $decoded['phase'] !== 2 && $decoded['phase'] !== 3) {
|
|
$decoded['phase'] = 1;
|
|
}
|
|
$decoded['history'] = is_array($decoded['history'] ?? null) ? $decoded['history'] : [];
|
|
return $decoded;
|
|
}
|
|
|
|
function save_state(string $uuid, array $state): void {
|
|
$state['updatedAt'] = gmdate('c');
|
|
$path = DATA_DIR . '/' . $uuid . '.json';
|
|
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
if ($json === false) {
|
|
fail(500, 'Could not encode state');
|
|
}
|
|
if (file_put_contents($path, $json, LOCK_EX) === false) {
|
|
fail(500, 'Could not save state');
|
|
}
|
|
}
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
|
$body = [];
|
|
if ($method === 'POST') {
|
|
$rawBody = file_get_contents('php://input');
|
|
if ($rawBody !== false && $rawBody !== '') {
|
|
$parsed = json_decode($rawBody, true);
|
|
if (is_array($parsed)) {
|
|
$body = $parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
$action = $_GET['action'] ?? ($body['action'] ?? 'state');
|
|
$uuid = $_GET['uuid'] ?? ($body['uuid'] ?? '');
|
|
|
|
if (!preg_match(UUID_PATTERN, $uuid)) {
|
|
fail(400, 'Invalid UUID');
|
|
}
|
|
|
|
$state = load_state($uuid);
|
|
|
|
if ($action === 'state') {
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($method !== 'POST') {
|
|
fail(405, 'Use POST for mutations');
|
|
}
|
|
|
|
if ($action === 'add_user') {
|
|
if ((int)$state['phase'] !== 1 || $state['setupDone']) {
|
|
fail(409, 'Setup is closed');
|
|
}
|
|
$user = clean_user((string)($body['user'] ?? ''));
|
|
if (!in_array($user, $state['users'], true)) {
|
|
$state['users'][] = $user;
|
|
}
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'finish_setup') {
|
|
if (count($state['users']) < 1) {
|
|
fail(409, 'Add at least one user first');
|
|
}
|
|
$state['setupDone'] = true;
|
|
$state['phase'] = 1;
|
|
$state['doneUsersPhase1'] = [];
|
|
$state['doneUsersPhase2'] = [];
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'add_movie') {
|
|
if (!$state['setupDone']) {
|
|
fail(409, 'Setup is not finished');
|
|
}
|
|
if ((int)$state['phase'] !== 1) {
|
|
fail(409, 'Movie phase is closed');
|
|
}
|
|
$user = clean_user((string)($body['user'] ?? ''));
|
|
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
|
fail(403, 'Unknown user');
|
|
}
|
|
$title = clean_movie((string)($body['title'] ?? ''));
|
|
$userMovieCount = 0;
|
|
foreach ($state['movies'] as $movie) {
|
|
if (!is_array($movie)) {
|
|
continue;
|
|
}
|
|
if (mb_strtolower((string)($movie['addedBy'] ?? '')) === mb_strtolower($user)) {
|
|
$userMovieCount++;
|
|
}
|
|
}
|
|
if ($userMovieCount >= 5) {
|
|
fail(409, 'Movie limit reached for this user (5)');
|
|
}
|
|
foreach ($state['movies'] as $existing) {
|
|
if (mb_strtolower((string)($existing['title'] ?? '')) === mb_strtolower($title)) {
|
|
fail(409, 'Movie already exists');
|
|
}
|
|
}
|
|
$state['movies'][] = [
|
|
'title' => $title,
|
|
'addedBy' => $user,
|
|
'addedAt' => gmdate('c'),
|
|
];
|
|
$state['doneUsersPhase1'] = array_values(array_filter(
|
|
$state['doneUsersPhase1'],
|
|
static fn(string $name): bool => $name !== $user,
|
|
));
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'remove_movie') {
|
|
if (!$state['setupDone']) {
|
|
fail(409, 'Setup is not finished');
|
|
}
|
|
if ((int)$state['phase'] !== 1) {
|
|
fail(409, 'Movie phase is closed');
|
|
}
|
|
$user = clean_user((string)($body['user'] ?? ''));
|
|
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
|
fail(403, 'Unknown user');
|
|
}
|
|
$title = clean_movie((string)($body['title'] ?? ''));
|
|
$state['movies'] = array_values(array_filter(
|
|
$state['movies'],
|
|
static fn(array $m): bool => !(mb_strtolower((string)($m['title'] ?? '')) === mb_strtolower($title) && mb_strtolower((string)($m['addedBy'] ?? '')) === mb_strtolower($user)),
|
|
));
|
|
$state['doneUsersPhase1'] = array_values(array_filter(
|
|
$state['doneUsersPhase1'],
|
|
static fn(string $name): bool => $name !== $user,
|
|
));
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'vote_many') {
|
|
if ((int)$state['phase'] !== 2) {
|
|
fail(409, 'Voting phase is not active');
|
|
}
|
|
$user = clean_user((string)($body['user'] ?? ''));
|
|
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
|
fail(403, 'Unknown user');
|
|
}
|
|
$ratings = $body['ratings'] ?? null;
|
|
if (!is_array($ratings)) {
|
|
fail(400, 'Ratings payload missing');
|
|
}
|
|
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
|
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
|
$state['votes'][$user] = [];
|
|
}
|
|
foreach ($movieTitles as $title) {
|
|
$rawRating = $ratings[$title] ?? null;
|
|
if (!is_int($rawRating)) {
|
|
if (is_numeric($rawRating)) {
|
|
$rawRating = (int)$rawRating;
|
|
} else {
|
|
fail(400, 'Each movie needs a rating');
|
|
}
|
|
}
|
|
if ($rawRating < 0 || $rawRating > 5) {
|
|
fail(400, 'Rating must be 0 to 5');
|
|
}
|
|
$state['votes'][$user][$title] = $rawRating;
|
|
}
|
|
$state['doneUsersPhase2'] = array_values(array_filter(
|
|
$state['doneUsersPhase2'],
|
|
static fn(string $name): bool => $name !== $user,
|
|
));
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'mark_done') {
|
|
if (!$state['setupDone']) {
|
|
fail(409, 'Setup is not finished');
|
|
}
|
|
$user = clean_user((string)($body['user'] ?? ''));
|
|
if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) {
|
|
fail(403, 'Unknown user');
|
|
}
|
|
$phase = (int)($body['phase'] ?? $state['phase']);
|
|
if ($phase === 1) {
|
|
if ((int)$state['phase'] !== 1) {
|
|
fail(409, 'Movie phase is not active');
|
|
}
|
|
if (!in_array($user, $state['doneUsersPhase1'], true)) {
|
|
$state['doneUsersPhase1'][] = $user;
|
|
}
|
|
if (all_users_done($state['users'], $state['doneUsersPhase1'])) {
|
|
$state['phase'] = 2;
|
|
$state['doneUsersPhase2'] = [];
|
|
}
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
if ($phase === 2) {
|
|
if ((int)$state['phase'] !== 2) {
|
|
fail(409, 'Voting phase is not active');
|
|
}
|
|
$movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']);
|
|
if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) {
|
|
fail(409, 'Save your ratings first');
|
|
}
|
|
foreach ($movieTitles as $title) {
|
|
$value = $state['votes'][$user][$title] ?? null;
|
|
if (!is_int($value)) {
|
|
fail(409, 'Rate every movie before finishing');
|
|
}
|
|
}
|
|
if (!in_array($user, $state['doneUsersPhase2'], true)) {
|
|
$state['doneUsersPhase2'][] = $user;
|
|
}
|
|
if (all_users_done($state['users'], $state['doneUsersPhase2'])) {
|
|
$state['phase'] = 3;
|
|
}
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
fail(400, 'Invalid done phase');
|
|
}
|
|
|
|
if ($action === 'set_phase') {
|
|
if (!$state['setupDone']) {
|
|
fail(409, 'Finish setup first');
|
|
}
|
|
$phase = (int)($body['phase'] ?? 0);
|
|
if ($phase !== 1 && $phase !== 2 && $phase !== 3) {
|
|
fail(400, 'Invalid phase');
|
|
}
|
|
$state['phase'] = $phase;
|
|
if ($phase === 1) {
|
|
$state['doneUsersPhase1'] = [];
|
|
$state['doneUsersPhase2'] = [];
|
|
}
|
|
if ($phase === 2) {
|
|
$state['doneUsersPhase2'] = [];
|
|
}
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'new_round') {
|
|
if ((int)$state['phase'] !== 3) {
|
|
fail(409, 'Round is not finished yet');
|
|
}
|
|
$winner = trim((string)($body['winner'] ?? ''));
|
|
// strip trophy emoji if present
|
|
$winner = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $winner);
|
|
$state['history'][] = [
|
|
'movies' => $state['movies'],
|
|
'winner' => $winner !== '' ? $winner : null,
|
|
];
|
|
$state['phase'] = 1;
|
|
$state['movies'] = [];
|
|
$state['votes'] = new stdClass();
|
|
$state['doneUsersPhase1'] = [];
|
|
$state['doneUsersPhase2'] = [];
|
|
save_state($uuid, $state);
|
|
echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
fail(404, 'Unknown action');
|