Files
movie-select/api.php
2026-03-01 11:44:21 +01:00

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