0, 'attempts' => []]; $data = json_decode(file_get_contents($file), true); if (!is_array($data)) return ['last_cleanup' => 0, 'attempts' => []]; if (!isset($data['attempts']) || !is_array($data['attempts'])) $data['attempts'] = []; if (!isset($data['last_cleanup'])) $data['last_cleanup'] = 0; return $data; } function save_logins(string $file, array $logins): void { file_put_contents($file, json_encode($logins, JSON_PRETTY_PRINT), LOCK_EX); } /* * cleanup rule: * - runs when we've passed the most recent 03:00 since last_cleanup * - only removes entries with < MAX_LOGIN_ATTEMPTS failures * - entries at or above the limit stay forever (permanent lockout) */ function cleanup_logins(array $logins, int $max_attempts): array { $now = time(); $today3 = strtotime('today 03:00'); $last3 = ($now >= $today3) ? $today3 : strtotime('yesterday 03:00'); if ($logins['last_cleanup'] < $last3) { foreach ($logins['attempts'] as $ip => $count) { if ($count < $max_attempts) { unset($logins['attempts'][$ip]); } } $logins['last_cleanup'] = $now; } return $logins; } function count_open_by_ip(array $issues, string $ip): int { $count = 0; foreach ($issues as $issue) { if ($issue['ip'] === $ip && $issue['status'] === 'open') $count++; } return $count; } function next_id(array $issues): int { $max = 0; foreach ($issues as $issue) { if ($issue['id'] > $max) $max = $issue['id']; } return $max + 1; } function is_admin(): bool { return isset($_SESSION['admin']) && $_SESSION['admin'] === true; } function is_locked_out(array $logins, string $ip, int $max_attempts): bool { $count = $logins['attempts'][$ip] ?? 0; return $count >= $max_attempts; } function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); } function fmt_date(int $ts): string { return date('j.n.Y', $ts); } // --- session & routing --- session_start(); $issues = load_issues($DATA_FILE); $logins = load_logins($LOGIN_FILE); $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; // run daily cleanup $logins_before = $logins; $logins = cleanup_logins($logins, $MAX_LOGIN_ATTEMPTS); if ($logins !== $logins_before) save_logins($LOGIN_FILE, $logins); $locked = is_locked_out($logins, $ip, $MAX_LOGIN_ATTEMPTS); $action = $_GET['action'] ?? 'list'; $msg = ''; $msg_type = ''; // --- handle POST actions --- if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (isset($_POST['action'])) { // create issue -- blocked if locked out if ($_POST['action'] === 'create') { if ($locked) { header('Location: ?action=list'); exit; } $title = trim($_POST['title'] ?? ''); $body = trim($_POST['body'] ?? ''); $category = $_POST['category'] ?? 'Issue'; $priority = $_POST['priority'] ?? 'medium'; if (!in_array($category, $CATEGORIES)) $category = 'Issue'; if (!in_array($priority, $PRIORITIES)) $priority = 'medium'; if ($title === '') { $msg = 'title is required.'; $msg_type = 'error'; } elseif (mb_strlen($title) > $MAX_TITLE) { $msg = "title too long (max {$MAX_TITLE} chars)."; $msg_type = 'error'; } elseif (mb_strlen($body) > $MAX_BODY) { $msg = "description too long (max {$MAX_BODY} chars)."; $msg_type = 'error'; } elseif (count_open_by_ip($issues, $ip) >= $MAX_OPEN_PER_IP) { $msg = "you already have {$MAX_OPEN_PER_IP} open issues. wait for one to be closed."; $msg_type = 'error'; } else { $issues[] = [ 'id' => next_id($issues), 'title' => $title, 'body' => $body, 'category' => $category, 'priority' => $priority, 'status' => 'open', 'ip' => $ip, 'created' => time(), ]; save_issues($DATA_FILE, $issues); header('Location: ?action=list&created=1'); exit; } } // admin login -- blocked if locked out if ($_POST['action'] === 'login') { if ($locked) { header('Location: ?action=list'); exit; } if (($_POST['password'] ?? '') === $ADMIN_PASSWORD) { $_SESSION['admin'] = true; if (isset($logins['attempts'][$ip])) { unset($logins['attempts'][$ip]); save_logins($LOGIN_FILE, $logins); } header('Location: ?action=list'); exit; } else { $logins['attempts'][$ip] = ($logins['attempts'][$ip] ?? 0) + 1; save_logins($LOGIN_FILE, $logins); if ($logins['attempts'][$ip] >= $MAX_LOGIN_ATTEMPTS) { header('Location: ?action=list'); exit; } $remaining = $MAX_LOGIN_ATTEMPTS - $logins['attempts'][$ip]; $msg = "wrong password. {$remaining} attempt(s) remaining."; $msg_type = 'error'; } } // admin logout if ($_POST['action'] === 'logout') { unset($_SESSION['admin']); header('Location: ?action=list'); exit; } // admin toggle status if ($_POST['action'] === 'toggle' && is_admin()) { $toggle_id = (int)($_POST['id'] ?? 0); foreach ($issues as &$issue) { if ($issue['id'] === $toggle_id) { $issue['status'] = $issue['status'] === 'open' ? 'closed' : 'open'; break; } } unset($issue); save_issues($DATA_FILE, $issues); header('Location: ?action=view&id=' . $toggle_id); exit; } // admin edit issue if ($_POST['action'] === 'edit' && is_admin()) { $edit_id = (int)($_POST['id'] ?? 0); $e_title = trim($_POST['title'] ?? ''); $e_body = trim($_POST['body'] ?? ''); $e_category = $_POST['category'] ?? 'Issue'; $e_priority = $_POST['priority'] ?? 'medium'; if (!in_array($e_category, $CATEGORIES)) $e_category = 'Issue'; if (!in_array($e_priority, $PRIORITIES)) $e_priority = 'medium'; if ($e_title === '') { $msg = 'title is required.'; $msg_type = 'error'; } elseif (mb_strlen($e_title) > $MAX_TITLE) { $msg = "title too long (max {$MAX_TITLE} chars)."; $msg_type = 'error'; } elseif (mb_strlen($e_body) > $MAX_BODY) { $msg = "description too long (max {$MAX_BODY} chars)."; $msg_type = 'error'; } else { foreach ($issues as &$issue) { if ($issue['id'] === $edit_id) { $issue['title'] = $e_title; $issue['body'] = $e_body; $issue['category'] = $e_category; $issue['priority'] = $e_priority; break; } } unset($issue); save_issues($DATA_FILE, $issues); header('Location: ?action=view&id=' . $edit_id . '&edited=1'); exit; } } // admin delete if ($_POST['action'] === 'delete' && is_admin()) { $del_id = (int)($_POST['id'] ?? 0); $issues = array_values(array_filter($issues, fn($i) => $i['id'] !== $del_id)); save_issues($DATA_FILE, $issues); header('Location: ?action=list&deleted=1'); exit; } // admin ban user by ip (via issue) if ($_POST['action'] === 'ban' && is_admin()) { $ban_id = (int)($_POST['id'] ?? 0); $ban_ip = null; foreach ($issues as $i) { if ($i['id'] === $ban_id) { $ban_ip = $i['ip']; break; } } if ($ban_ip !== null) { // set attempts to the max so cleanup will never remove it $logins['attempts'][$ban_ip] = $MAX_LOGIN_ATTEMPTS; save_logins($LOGIN_FILE, $logins); header('Location: ?action=view&id=' . $ban_id . '&banned=1'); exit; } } // admin unban user by ip (via issue) if ($_POST['action'] === 'unban' && is_admin()) { $unban_id = (int)($_POST['id'] ?? 0); $unban_ip = null; foreach ($issues as $i) { if ($i['id'] === $unban_id) { $unban_ip = $i['ip']; break; } } if ($unban_ip !== null && isset($logins['attempts'][$unban_ip])) { unset($logins['attempts'][$unban_ip]); save_logins($LOGIN_FILE, $logins); header('Location: ?action=view&id=' . $unban_id . '&unbanned=1'); exit; } } } } // --- if locked out, silently force list view --- if ($locked && !is_admin()) { if ($action === 'login' || $action === 'new') { header('Location: ?action=list'); exit; } } // --- flash messages from redirects --- if (isset($_GET['created'])) { $msg = 'issue created.'; $msg_type = 'success'; } if (isset($_GET['deleted'])) { $msg = 'issue deleted.'; $msg_type = 'success'; } if (isset($_GET['edited'])) { $msg = 'issue updated.'; $msg_type = 'success'; } if (isset($_GET['banned'])) { $msg = 'user banned.'; $msg_type = 'success'; } if (isset($_GET['unbanned'])) { $msg = 'user unbanned.'; $msg_type = 'success'; } // --- filter/sort for list --- $filter_status = $_GET['status'] ?? 'open'; $filter_category = $_GET['category'] ?? ''; $filtered = array_filter($issues, function($i) use ($filter_status, $filter_category) { if ($filter_status && $i['status'] !== $filter_status) return false; if ($filter_category && $i['category'] !== $filter_category) return false; return true; }); $priority_order = ['high' => 0, 'medium' => 1, 'low' => 2]; usort($filtered, function($a, $b) use ($priority_order) { $sa = $a['status'] === 'open' ? 0 : 1; $sb = $b['status'] === 'open' ? 0 : 1; if ($sa !== $sb) return $sa - $sb; $pa = $priority_order[$a['priority']] ?? 1; $pb = $priority_order[$b['priority']] ?? 1; if ($pa !== $pb) return $pa - $pb; return $b['created'] - $a['created']; }); // --- view single issue --- $view_issue = null; if ($action === 'view') { $view_id = (int)($_GET['id'] ?? 0); foreach ($issues as $i) { if ($i['id'] === $view_id) { $view_issue = $i; break; } } if (!$view_issue) { $action = 'list'; $msg = 'issue not found.'; $msg_type = 'error'; } } $open_count = count(array_filter($issues, fn($i) => $i['status'] === 'open')); $closed_count = count(array_filter($issues, fn($i) => $i['status'] === 'closed')); $my_open = count_open_by_ip($issues, $ip); $ev_title = $_POST['title'] ?? $view_issue['title'] ?? ''; $ev_body = $_POST['body'] ?? $view_issue['body'] ?? ''; $ev_category = $_POST['category'] ?? $view_issue['category'] ?? 'Issue'; $ev_priority = $_POST['priority'] ?? $view_issue['priority'] ?? 'medium'; // check if viewed issue's ip is banned $view_ip_banned = false; if ($view_issue) { $view_ip_banned = is_locked_out($logins, $view_issue['ip'], $MAX_LOGIN_ATTEMPTS); } ?>
you have = $my_open ?>/= $MAX_OPEN_PER_IP ?> open issues.
no issues found.