<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
/* ─── Storage bootstrap ───────────────────────────────────────────── */
function init_storage(): void {
foreach ([DATA_DIR, USERS_DIR, LISTS_DIR] as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
}
if (!file_exists(USERS_INDEX)) {
save_json(USERS_INDEX, []);
}
init_uploads();
migrate_slugs();
}
function migrate_slugs(): void {
// Backfill slug field on any lists that predate slug support
foreach (glob(LISTS_DIR . '/*', GLOB_ONLYDIR) as $udir) {
$username = basename($udir);
foreach (glob($udir . '/*.json') as $file) {
$list = read_json($file);
if (!$list || isset($list['slug'])) continue;
$list['slug'] = make_unique_slug($username, $list['title'] ?? 'list', $list['id'] ?? null);
save_json($file, $list);
}
}
}
/* ─── Atomic JSON helpers ─────────────────────────────────────────── */
function read_json(string $path): array {
if (!file_exists($path)) return [];
$raw = file_get_contents($path);
return ($raw !== false) ? (json_decode($raw, true) ?? []) : [];
}
function save_json(string $path, array $data): void {
$tmp = $path . '.tmp.' . getmypid();
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
rename($tmp, $path);
}
/* ─── Input validation ────────────────────────────────────────────── */
function validate_username(string $u): bool {
return (bool) preg_match('/^[a-z][a-z0-9_-]{' . (MIN_USERNAME_LEN - 1) . ',' . (MAX_USERNAME_LEN - 1) . '}$/', $u);
}
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/* ─── CSRF ────────────────────────────────────────────────────────── */
function csrf_token(): string {
session_start_safe();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(): bool {
session_start_safe();
$token = $_POST['csrf_token'] ?? '';
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function csrf_field(): string {
return '<input type="hidden" name="csrf_token" value="' . h(csrf_token()) . '">';
}
/* ─── Flash messages ──────────────────────────────────────────────── */
function flash(string $type, string $message): void {
session_start_safe();
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array {
session_start_safe();
$f = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $f;
}
/* ─── Session ─────────────────────────────────────────────────────── */
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_NAME);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => false,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
}
function get_session_user(): ?array {
session_start_safe();
return $_SESSION['user'] ?? null;
}
function require_login(): array {
$u = get_session_user();
if (!$u) {
header('Location: index.php');
exit;
}
return $u;
}
function login_user(array $user): void {
session_start_safe();
session_regenerate_id(true);
$_SESSION['user'] = $user;
}
function logout_user(): void {
session_start_safe();
$_SESSION = [];
session_destroy();
}
/* ─── URL / upload helpers ────────────────────────────────────────── */
function site_base_url(): string {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return rtrim($scheme . '://' . $host, '/');
}
function init_uploads(): void {
$avatar_dir = UPLOADS_DIR . '/avatars';
foreach ([UPLOADS_DIR, $avatar_dir] as $d) {
if (!is_dir($d)) mkdir($d, 0755, true);
}
}
/**
* Validates an uploaded image file, moves it to $dest_dir/$base_name.{ext},
* removes any previous file with the same base name, returns the filename or false.
*/
function save_uploaded_image(array $file, string $dest_dir, string $base_name): string|false {
if ($file['error'] !== UPLOAD_ERR_OK) return false;
if ($file['size'] > 2 * 1024 * 1024) return false;
$info = @getimagesize($file['tmp_name']);
if (!$info) return false;
$ext_map = [
IMAGETYPE_JPEG => 'jpg',
IMAGETYPE_PNG => 'png',
IMAGETYPE_GIF => 'gif',
IMAGETYPE_WEBP => 'webp',
];
$ext = $ext_map[$info[2] ?? 0] ?? null;
if (!$ext) return false;
if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);
// Remove any existing file with the same base regardless of extension
foreach (glob($dest_dir . '/' . $base_name . '.*') ?: [] as $old) {
unlink($old);
}
$dest = $dest_dir . '/' . $base_name . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dest)) return false;
return $base_name . '.' . $ext;
}
function delete_upload(string $dest_dir, string $base_name): void {
foreach (glob($dest_dir . '/' . $base_name . '.*') ?: [] as $f) {
unlink($f);
}
}
function get_og_logo_url(): string {
$cfg = get_site_config();
$logo = $cfg['og_logo'] ?? '';
if ($logo && file_exists(UPLOADS_DIR . '/' . $logo)) {
return site_base_url() . '/uploads/' . rawurlencode($logo);
}
return '';
}
function get_user_avatar_url(string $username): string {
$username = strtolower($username);
$profile = get_user_profile($username);
$avatar = $profile['avatar'] ?? '';
if ($avatar && file_exists(UPLOADS_DIR . '/avatars/' . $avatar)) {
return site_base_url() . '/uploads/avatars/' . rawurlencode($avatar);
}
return '';
}
/* ─── Site configuration ──────────────────────────────────────────── */
define('SITE_CONFIG_FILE', DATA_DIR . '/site_config.json');
define('DEFAULT_FOOTER_TEXT', 'TaskGram by Amfile.org of Page Telegram Volunteer Services Copyright {year}');
function get_site_config(): array {
$cfg = read_json(SITE_CONFIG_FILE);
return array_merge(['footer_text' => DEFAULT_FOOTER_TEXT], $cfg);
}
function save_site_config(array $cfg): void {
save_json(SITE_CONFIG_FILE, $cfg);
}
function get_footer_text(): string {
$cfg = get_site_config();
$text = $cfg['footer_text'] ?? DEFAULT_FOOTER_TEXT;
return str_replace('{year}', date('Y'), $text);
}
function is_admin(array $user): bool {
return !empty($user['admin']);
}
/* ─── User management ─────────────────────────────────────────────── */
function user_exists(string $username): bool {
$idx = read_json(USERS_INDEX);
return isset($idx[strtolower($username)]);
}
function create_user(string $username, string $password, string $display_name, string $email): string|bool {
$username = strtolower(trim($username));
if (!validate_username($username)) return 'Invalid username (3-32 chars, letters/numbers/dash/underscore, start with letter)';
if (strlen($password) < MIN_PASSWORD_LEN) return 'Password must be at least ' . MIN_PASSWORD_LEN . ' characters';
if (user_exists($username)) return 'Username already taken';
if (trim($display_name) === '') $display_name = $username;
if (!filter_var($email, FILTER_VALIDATE_EMAIL) && $email !== '') return 'Invalid email address';
$user_dir = USERS_DIR . '/' . $username;
if (!is_dir($user_dir)) mkdir($user_dir, 0750, true);
$lists_dir = LISTS_DIR . '/' . $username;
if (!is_dir($lists_dir)) mkdir($lists_dir, 0750, true);
$idx = read_json(USERS_INDEX);
$is_first = empty($idx); // First user becomes admin
$profile = [
'username' => $username,
'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
'display_name' => trim($display_name),
'email' => $email,
'created' => date('Y-m-d H:i:s'),
'admin' => $is_first,
];
save_json($user_dir . '/profile.json', $profile);
$idx[$username] = ['display_name' => $profile['display_name'], 'created' => $profile['created']];
save_json(USERS_INDEX, $idx);
return true;
}
function authenticate(string $username, string $password): array|string {
$username = strtolower(trim($username));
$file = USERS_DIR . '/' . $username . '/profile.json';
if (!file_exists($file)) return 'Invalid username or password';
$profile = read_json($file);
if (!password_verify($password, $profile['password_hash'] ?? '')) return 'Invalid username or password';
unset($profile['password_hash']);
return $profile;
}
function get_user_profile(string $username): ?array {
$file = USERS_DIR . '/' . strtolower($username) . '/profile.json';
if (!file_exists($file)) return null;
$p = read_json($file);
unset($p['password_hash']);
return $p;
}
function update_password(string $username, string $old_password, string $new_password): string|bool {
$username = strtolower($username);
$file = USERS_DIR . '/' . $username . '/profile.json';
$profile = read_json($file);
if (!password_verify($old_password, $profile['password_hash'] ?? '')) return 'Current password incorrect';
if (strlen($new_password) < MIN_PASSWORD_LEN) return 'New password must be at least ' . MIN_PASSWORD_LEN . ' characters';
$profile['password_hash'] = password_hash($new_password, PASSWORD_BCRYPT, ['cost' => 12]);
save_json($file, $profile);
return true;
}
function save_user_avatar(string $username, array $file): string|false {
init_uploads();
$filename = save_uploaded_image($file, UPLOADS_DIR . '/avatars', $username);
if (!$filename) return false;
$profile_file = USERS_DIR . '/' . $username . '/profile.json';
$profile = read_json($profile_file);
$profile['avatar'] = $filename;
save_json($profile_file, $profile);
return $filename;
}
function remove_user_avatar(string $username): void {
delete_upload(UPLOADS_DIR . '/avatars', $username);
$profile_file = USERS_DIR . '/' . $username . '/profile.json';
$profile = read_json($profile_file);
unset($profile['avatar']);
save_json($profile_file, $profile);
}
function update_display_name(string $username, string $display_name): void {
$username = strtolower($username);
$file = USERS_DIR . '/' . $username . '/profile.json';
$profile = read_json($file);
$profile['display_name'] = trim($display_name) ?: $username;
save_json($file, $profile);
$idx = read_json(USERS_INDEX);
if (isset($idx[$username])) {
$idx[$username]['display_name'] = $profile['display_name'];
save_json(USERS_INDEX, $idx);
}
}
/* ─── Slug helpers ────────────────────────────────────────────────── */
function slugify(string $title): string {
$s = mb_strtolower($title, 'UTF-8');
// Transliterate common accented chars
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s;
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
$s = trim($s, '-');
$s = substr($s, 0, 64);
return $s ?: 'list';
}
function make_unique_slug(string $username, string $title, ?string $exclude_id = null): string {
$base = slugify($title);
$dir = LISTS_DIR . '/' . strtolower($username);
$slugs = [];
if (is_dir($dir)) {
foreach (glob($dir . '/*.json') as $file) {
$data = read_json($file);
if (!$data) continue;
if ($exclude_id && ($data['id'] ?? '') === $exclude_id) continue;
if (isset($data['slug'])) $slugs[] = $data['slug'];
}
}
$candidate = $base;
$counter = 2;
while (in_array($candidate, $slugs, true)) {
$candidate = $base . '-' . $counter++;
}
return $candidate;
}
/* ─── List management ─────────────────────────────────────────────── */
function get_lists_for_user(string $username): array {
$dir = LISTS_DIR . '/' . strtolower($username);
if (!is_dir($dir)) return [];
$lists = [];
foreach (glob($dir . '/*.json') as $file) {
$list = read_json($file);
if ($list) {
$list['active_count'] = count($list['active'] ?? []);
$list['completed_count'] = count($list['completed'] ?? []);
unset($list['active'], $list['completed']);
$lists[] = $list;
}
}
usort($lists, fn($a, $b) => strcmp($b['updated'] ?? '', $a['updated'] ?? ''));
return $lists;
}
function get_public_lists(int $limit = 100): array {
$all = [];
foreach (glob(LISTS_DIR . '/*', GLOB_ONLYDIR) as $udir) {
$username = basename($udir);
foreach (glob($udir . '/*.json') as $file) {
$list = read_json($file);
if ($list && ($list['public'] ?? false)) {
$list['active_count'] = count($list['active'] ?? []);
$list['completed_count'] = count($list['completed'] ?? []);
unset($list['active'], $list['completed']);
$all[] = $list;
}
}
}
usort($all, fn($a, $b) => strcmp($b['updated'] ?? '', $a['updated'] ?? ''));
return array_slice($all, 0, $limit);
}
function get_list(string $username, string $list_id): ?array {
$id = preg_replace('/[^a-f0-9]/', '', $list_id);
$file = LISTS_DIR . '/' . strtolower($username) . '/' . $id . '.json';
if (!file_exists($file)) return null;
return read_json($file);
}
function save_list(array $list): void {
$dir = LISTS_DIR . '/' . $list['owner'];
if (!is_dir($dir)) mkdir($dir, 0750, true);
save_json($dir . '/' . $list['id'] . '.json', $list);
}
function create_list(string $username, string $title, string $description, bool $public): array {
$title = trim($title);
$list = [
'id' => bin2hex(random_bytes(8)),
'slug' => make_unique_slug($username, $title),
'title' => $title,
'description' => trim($description),
'owner' => strtolower($username),
'public' => $public,
'created' => date('Y-m-d H:i:s'),
'updated' => date('Y-m-d H:i:s'),
'active' => [],
'completed' => [],
];
save_list($list);
return $list;
}
function update_list_meta(string $username, string $list_id, string $title, string $description, bool $public): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
$title = trim($title);
// Regenerate slug if title changed
if ($title !== $list['title']) {
$list['slug'] = make_unique_slug($username, $title, $list_id);
}
$list['title'] = $title;
$list['description'] = trim($description);
$list['public'] = $public;
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
function get_list_by_slug(string $username, string $slug): ?array {
$dir = LISTS_DIR . '/' . strtolower($username);
if (!is_dir($dir)) return null;
foreach (glob($dir . '/*.json') as $file) {
$data = read_json($file);
if ($data && ($data['slug'] ?? '') === $slug) return $data;
}
return null;
}
function delete_list(string $username, string $list_id): bool {
$id = preg_replace('/[^a-f0-9]/', '', $list_id);
$file = LISTS_DIR . '/' . strtolower($username) . '/' . $id . '.json';
if (!file_exists($file)) return false;
return unlink($file);
}
function can_view_list(array $list, ?array $current_user): bool {
if ($list['public']) return true;
if ($current_user && $current_user['username'] === $list['owner']) return true;
return has_list_access($list['id'] ?? ''); // token or password grant in session
}
function generate_share_token(string $username, string $list_id): string {
$list = get_list($username, $list_id);
if (!$list) return '';
$token = bin2hex(random_bytes(16));
$list['share_token'] = $token;
save_list($list);
return $token;
}
function revoke_share_token(string $username, string $list_id): void {
$list = get_list($username, $list_id);
if (!$list) return;
unset($list['share_token']);
save_list($list);
}
function check_share_token(array $list, string $token): bool {
if ($token === '') return false;
return isset($list['share_token']) && hash_equals($list['share_token'], $token);
}
function can_edit_list(array $list, ?array $current_user): bool {
if (!$current_user) return false;
return $current_user['username'] === $list['owner'];
}
/* ─── Task management ─────────────────────────────────────────────── */
function parse_due_input(string $input): string {
$input = trim($input);
if ($input === '') return '';
// datetime-local sends "Y-m-d\TH:i" — normalise to "Y-m-d H:i:s"
$ts = strtotime(str_replace('T', ' ', $input));
return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : '';
}
function due_status(string $due): array {
if ($due === '') return ['class' => '', 'label' => ''];
$ts = strtotime($due);
if ($ts === false) return ['class' => '', 'label' => ''];
$now = time();
$diff = $ts - $now;
if ($diff < 0) {
return ['class' => 'due-overdue', 'label' => 'Overdue'];
}
if ($diff < 86400) { // within 24 h
return ['class' => 'due-today', 'label' => 'Due today'];
}
if ($diff < 172800) { // within 48 h
return ['class' => 'due-soon', 'label' => 'Due tomorrow'];
}
return ['class' => 'due-future', 'label' => 'Due ' . date('d M Y', $ts)];
}
function add_task(string $username, string $list_id, string $text, string $priority = 'normal', string $due = ''): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
$task = [
'id' => bin2hex(random_bytes(6)),
'text' => trim($text),
'priority' => in_array($priority, ['high','normal','low']) ? $priority : 'normal',
'created' => date('Y-m-d H:i:s'),
];
if ($due !== '') $task['due'] = $due;
$list['active'][] = $task;
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
function edit_task(string $username, string $list_id, string $task_id, string $text, string $priority, ?int $order = null, string $due = '', bool $clear_due = false): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
foreach ($list['active'] as &$task) {
if ($task['id'] === $task_id) {
$task['text'] = trim($text);
$task['priority'] = in_array($priority, ['high','normal','low']) ? $priority : 'normal';
if ($order !== null) {
$task['order'] = max(1, $order);
} elseif (isset($task['order'])) {
unset($task['order']); // clearing order field
}
if ($clear_due) {
unset($task['due']);
} elseif ($due !== '') {
$task['due'] = $due;
}
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
}
return false;
}
function complete_task(string $username, string $list_id, string $task_id): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
foreach ($list['active'] as $i => $task) {
if ($task['id'] === $task_id) {
$task['completed'] = date('Y-m-d H:i:s');
array_unshift($list['completed'], $task);
array_splice($list['active'], $i, 1);
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
}
return false;
}
function uncomplete_task(string $username, string $list_id, string $task_id): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
foreach ($list['completed'] as $i => $task) {
if ($task['id'] === $task_id) {
unset($task['completed']);
$list['active'][] = $task;
array_splice($list['completed'], $i, 1);
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
}
return false;
}
function delete_task(string $username, string $list_id, string $task_id, string $pool = 'active'): bool {
$pool = $pool === 'completed' ? 'completed' : 'active';
$list = get_list($username, $list_id);
if (!$list) return false;
foreach ($list[$pool] as $i => $task) {
if ($task['id'] === $task_id) {
array_splice($list[$pool], $i, 1);
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
}
return false;
}
function move_task_to_list(string $username, string $from_list_id, string $task_id, string $to_list_id): bool {
if ($from_list_id === $to_list_id) return false;
$from = get_list($username, $from_list_id);
$to = get_list($username, $to_list_id);
if (!$from || !$to) return false;
foreach ($from['active'] as $i => $task) {
if ($task['id'] === $task_id) {
$task['id'] = bin2hex(random_bytes(6)); // fresh ID in new list
$task['created'] = $task['created'] ?? date('Y-m-d H:i:s');
$to['active'][] = $task;
array_splice($from['active'], $i, 1);
$from['updated'] = $to['updated'] = date('Y-m-d H:i:s');
save_list($from);
save_list($to);
return true;
}
}
return false;
}
function move_task(string $username, string $list_id, string $task_id, string $direction): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
foreach ($list['active'] as $i => $task) {
if ($task['id'] === $task_id) {
$j = $direction === 'up' ? $i - 1 : $i + 1;
if ($j < 0 || $j >= count($list['active'])) return false;
[$list['active'][$i], $list['active'][$j]] = [$list['active'][$j], $list['active'][$i]];
save_list($list);
return true;
}
}
return false;
}
function clear_completed(string $username, string $list_id): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
$list['completed'] = [];
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return true;
}
/* ─── Password-protected public lists ────────────────────────────── */
define('RATE_LIMIT_DIR', DATA_DIR . '/rate_limits');
define('RATE_LIMIT_MAX', 3);
define('RATE_LIMIT_WINDOW', 900); // 15 minutes in seconds
function init_rate_limit_storage(): void {
if (!is_dir(RATE_LIMIT_DIR)) mkdir(RATE_LIMIT_DIR, 0750, true);
}
function rate_limit_key(string $list_id): string {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return RATE_LIMIT_DIR . '/' . hash('sha256', $ip . '_' . $list_id) . '.json';
}
function check_rate_limit(string $list_id): bool {
// Returns true if request is ALLOWED, false if BLOCKED
init_rate_limit_storage();
$file = rate_limit_key($list_id);
$data = read_json($file);
$now = time();
if (empty($data['window_start']) || ($now - $data['window_start']) >= RATE_LIMIT_WINDOW) {
// Window expired or new entry — allow
return true;
}
return ($data['attempts'] ?? 0) < RATE_LIMIT_MAX;
}
function record_failed_attempt(string $list_id): void {
init_rate_limit_storage();
$file = rate_limit_key($list_id);
$data = read_json($file);
$now = time();
if (empty($data['window_start']) || ($now - $data['window_start']) >= RATE_LIMIT_WINDOW) {
$data = ['window_start' => $now, 'attempts' => 1];
} else {
$data['attempts'] = ($data['attempts'] ?? 0) + 1;
}
save_json($file, $data);
}
function reset_rate_limit(string $list_id): void {
$file = rate_limit_key($list_id);
if (file_exists($file)) unlink($file);
}
function get_rate_limit_wait(string $list_id): int {
// Returns seconds remaining in lockout (0 if not locked)
$file = rate_limit_key($list_id);
$data = read_json($file);
if (empty($data['window_start'])) return 0;
$elapsed = time() - $data['window_start'];
$remaining = RATE_LIMIT_WINDOW - $elapsed;
if ($remaining <= 0 || ($data['attempts'] ?? 0) < RATE_LIMIT_MAX) return 0;
return (int) $remaining;
}
function generate_captcha(): array {
// Returns ['question' => '...', 'answer' => N]
$a = random_int(1, 12);
$b = random_int(1, 12);
$op = ['+', '-', '*'][random_int(0, 2)];
$answer = match($op) {
'+' => $a + $b,
'-' => $a - $b,
'*' => $a * $b,
};
return ['question' => "$a $op $b", 'answer' => $answer];
}
function set_captcha(): string {
session_start_safe();
$cap = generate_captcha();
$_SESSION['captcha_answer'] = $cap['answer'];
return $cap['question'];
}
function verify_captcha(string $user_answer): bool {
session_start_safe();
$expected = $_SESSION['captcha_answer'] ?? null;
if ($expected === null) return false;
unset($_SESSION['captcha_answer']);
return (int) trim($user_answer) === (int) $expected;
}
function set_list_password(string $username, string $list_id, string $password): bool {
$list = get_list($username, $list_id);
if (!$list) return false;
if ($password === '') {
$list['password_hash'] = null;
} else {
$list['password_hash'] = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
}
save_list($list);
return true;
}
function verify_list_password(array $list, string $password): bool {
if (empty($list['password_hash'])) return true; // no password set
return password_verify($password, $list['password_hash']);
}
function grant_list_access(string $list_id): void {
session_start_safe();
$_SESSION['list_access_' . $list_id] = true;
}
function has_list_access(string $list_id): bool {
session_start_safe();
return !empty($_SESSION['list_access_' . $list_id]);
}
/* ─── Hit counting ────────────────────────────────────────────────── */
function visitor_ip(): string {
// Respect common reverse-proxy headers; take only the first (client) address.
$raw = $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['HTTP_X_REAL_IP']
?? $_SERVER['REMOTE_ADDR']
?? '0.0.0.0';
$ip = trim(explode(',', $raw)[0]);
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
}
function is_likely_bot(): bool {
$ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
if ($ua === '') return true;
foreach ([
'bot','crawler','spider','slurp','feed','rss',
'curl','wget','python','ruby','perl','java','go-http',
'scrapy','httrack','archiver','validator','preview',
'facebookexternalhit','twitterbot','linkedinbot',
'whatsapp','telegram','discord','slack','skype',
'pinterest','applebot','googlebot','bingbot','yandex',
'baidu','duckduck','semrush','ahrefs','mj12',
] as $sig) {
if (str_contains($ua, $sig)) return true;
}
return false;
}
function record_list_hit(string $list_id): void {
if (is_likely_bot()) return;
if (!is_dir(HITS_DIR)) mkdir(HITS_DIR, 0750, true);
$file = HITS_DIR . '/' . $list_id . '.json';
$data = read_json($file);
$data['total'] = ($data['total'] ?? 0) + 1;
// Hash IP+list_id so the same visitor has a different token per list
$hash = hash('sha256', visitor_ip() . ':' . $list_id);
$unique = $data['unique'] ?? [];
if (!in_array($hash, $unique, true)) {
$unique[] = $hash;
// Hard cap: keeps file size bounded
if (count($unique) > 100_000) array_shift($unique);
$data['unique'] = $unique;
}
save_json($file, $data);
}
function get_list_hits(string $list_id): array {
$file = HITS_DIR . '/' . $list_id . '.json';
$data = read_json($file);
return [
'total' => (int) ($data['total'] ?? 0),
'unique' => count($data['unique'] ?? []),
];
}
/* ─── Sort ────────────────────────────────────────────────────────── */
function sort_tasks(array $tasks, string $sort, string $pool = 'active'): array {
switch ($sort) {
case 'created_asc':
usort($tasks, fn($a, $b) => strcmp($a['created'] ?? '', $b['created'] ?? ''));
break;
case 'created_desc':
usort($tasks, fn($a, $b) => strcmp($b['created'] ?? '', $a['created'] ?? ''));
break;
case 'priority':
$w = ['high' => 0, 'normal' => 1, 'low' => 2];
usort($tasks, fn($a, $b) =>
($w[$a['priority'] ?? 'normal'] ?? 1) - ($w[$b['priority'] ?? 'normal'] ?? 1));
break;
case 'alpha':
usort($tasks, fn($a, $b) => strcasecmp($a['text'] ?? '', $b['text'] ?? ''));
break;
case 'order':
usort($tasks, function($a, $b) {
$ao = isset($a['order']) ? (int)$a['order'] : PHP_INT_MAX;
$bo = isset($b['order']) ? (int)$b['order'] : PHP_INT_MAX;
return $ao - $bo;
});
break;
case 'completed_asc':
if ($pool === 'completed') {
usort($tasks, fn($a, $b) => strcmp($a['completed'] ?? '', $b['completed'] ?? ''));
}
break;
case 'completed_desc':
if ($pool === 'completed') {
usort($tasks, fn($a, $b) => strcmp($b['completed'] ?? '', $a['completed'] ?? ''));
}
break;
case 'due_asc':
usort($tasks, function($a, $b) {
$ad = isset($a['due']) && $a['due'] !== '' ? strtotime($a['due']) : PHP_INT_MAX;
$bd = isset($b['due']) && $b['due'] !== '' ? strtotime($b['due']) : PHP_INT_MAX;
return $ad - $bd;
});
break;
case 'due_desc':
usort($tasks, function($a, $b) {
$ad = isset($a['due']) && $a['due'] !== '' ? strtotime($a['due']) : 0;
$bd = isset($b['due']) && $b['due'] !== '' ? strtotime($b['due']) : 0;
return $bd - $ad;
});
break;
// 'default' → no sort, keep storage order
}
return $tasks;
}
/* ─── Export ──────────────────────────────────────────────────────── */
function generate_csv(array $list): string {
$fh = fopen('php://memory', 'w+');
// UTF-8 BOM for Excel compatibility
fwrite($fh, "\xEF\xBB\xBF");
fputcsv($fh, ['status', 'priority', 'order', 'text', 'created', 'completed', 'due']);
foreach ($list['active'] as $i => $t) {
fputcsv($fh, [
'active',
$t['priority'] ?? 'normal',
isset($t['order']) ? (int)$t['order'] : ($i + 1),
$t['text'] ?? '',
$t['created'] ?? '',
'',
$t['due'] ?? '',
]);
}
foreach ($list['completed'] as $t) {
fputcsv($fh, [
'completed',
$t['priority'] ?? 'normal',
'',
$t['text'] ?? '',
$t['created'] ?? '',
$t['completed'] ?? '',
$t['due'] ?? '',
]);
}
rewind($fh);
$csv = stream_get_contents($fh);
fclose($fh);
return $csv;
}
function generate_markdown(array $list): string {
$now = date('d F Y, H:i');
$md = '# ' . $list['title'] . "\n\n";
if ($list['description'] ?? '') {
$md .= '> ' . $list['description'] . "\n\n";
}
$md .= '**Owner:** @' . $list['owner'] . " \n";
$md .= '**Updated:** ' . date('d F Y, H:i', strtotime($list['updated'])) . " \n";
$md .= '**Visibility:** ' . ($list['public'] ? 'Public' : 'Private') . "\n\n";
$md .= "---\n\n";
$md .= '## Active Tasks (' . count($list['active']) . ")\n\n";
if (empty($list['active'])) {
$md .= "_No active tasks._\n\n";
} else {
foreach ($list['active'] as $i => $t) {
$pri = match($t['priority'] ?? 'normal') {
'high' => ' `HIGH`',
'low' => ' `LOW`',
default => '',
};
$ord = isset($t['order']) ? ' (' . (int)$t['order'] . ')' : '';
$due = (!empty($t['due'])) ? ' `DUE:' . date('d M Y H:i', strtotime($t['due'])) . '`' : '';
$md .= '- [ ] ' . $t['text'] . $pri . $ord . $due . "\n";
$md .= ' _Added ' . date('d M Y, H:i', strtotime($t['created'])) . "_\n";
}
}
$md .= "\n---\n\n";
$md .= '## Completed (' . count($list['completed']) . ")\n\n";
if (empty($list['completed'])) {
$md .= "_No completed tasks._\n\n";
} else {
foreach ($list['completed'] as $t) {
$md .= '- [x] ' . $t['text'] . "\n";
$md .= ' _Completed ' . date('d M Y, H:i', strtotime($t['completed'] ?? '')) . "_\n";
}
}
$md .= "\n---\n_Generated by TaskGram on $now_\n";
return $md;
}
/* ─── Import ──────────────────────────────────────────────────────── */
function parse_csv_import(string $content): array {
$fh = fopen('php://memory', 'r+');
// Strip BOM if present
$content = ltrim($content, "\xEF\xBB\xBF");
fwrite($fh, $content);
rewind($fh);
$tasks = [];
$header_map = null;
while (($row = fgetcsv($fh)) !== false) {
if ($row === [null]) continue;
if ($header_map === null) {
$lower = array_map(fn($c) => strtolower(trim($c ?? '')), $row);
// Detect header row: must contain 'text' or 'task'
if (in_array('text', $lower, true) || in_array('task', $lower, true)) {
$header_map = array_flip($lower);
continue;
}
$header_map = false; // no header
}
if ($header_map === false) {
// Headerless: col0=text, col1=status, col2=priority
$text = trim($row[0] ?? '');
$status = strtolower(trim($row[1] ?? 'active'));
$priority = strtolower(trim($row[2] ?? 'normal'));
$created = '';
$completed = '';
$order = null;
} else {
$col = fn(string $k) => trim($row[$header_map[$k] ?? -1] ?? '');
$text = $col('text') ?: $col('task') ?: $col('title');
$status = strtolower($col('status'));
$priority = strtolower($col('priority'));
$created = $col('created');
$completed = $col('completed');
$raw_order = $col('order');
$order = $raw_order !== '' ? max(1, (int)$raw_order) : null;
$due_raw = $col('due');
}
$text = trim($text);
if ($text === '') continue;
$is_done = in_array($status, ['completed','done','x','[x]','yes','1','true'], true);
$priority = in_array($priority, ['high','normal','low'], true) ? $priority : 'normal';
$task = [
'text' => $text,
'priority' => $priority,
'created' => ($created !== '') ? $created : date('Y-m-d H:i:s'),
'_status' => $is_done ? 'completed' : 'active',
];
if ($is_done && $completed !== '') $task['completed_at'] = $completed;
if ($order !== null) $task['order'] = $order;
if (!empty($due_raw ?? '')) {
$parsed_due = parse_due_input($due_raw);
if ($parsed_due !== '') $task['due'] = $parsed_due;
}
$tasks[] = $task;
}
fclose($fh);
return $tasks;
}
function parse_txt_import(string $content): array {
$tasks = [];
$lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $content));
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
$status = 'active';
$priority = 'normal';
// Markdown checkbox variants: - [x], * [x], - [ ], * [ ]
if (preg_match('/^[-*+]\s*\[x\]\s*/i', $line, $m)) {
$status = 'completed';
$line = substr($line, strlen($m[0]));
} elseif (preg_match('/^[-*+]\s*\[\s*\]\s*/', $line, $m)) {
$line = substr($line, strlen($m[0]));
} elseif (preg_match('/^[-*+]\s+/', $line, $m)) {
$line = substr($line, strlen($m[0]));
}
// Priority prefix [HIGH] / [LOW]
if (preg_match('/^\[HIGH\]\s*/i', $line, $m)) {
$priority = 'high';
$line = substr($line, strlen($m[0]));
} elseif (preg_match('/^\[LOW\]\s*/i', $line, $m)) {
$priority = 'low';
$line = substr($line, strlen($m[0]));
}
// Priority suffix !high / !low / !normal
if (preg_match('/\s+!high\s*$/i', $line)) {
$priority = 'high';
$line = trim(preg_replace('/\s+!high\s*$/i', '', $line));
} elseif (preg_match('/\s+!low\s*$/i', $line)) {
$priority = 'low';
$line = trim(preg_replace('/\s+!low\s*$/i', '', $line));
}
$line = trim($line);
if ($line === '') continue;
$tasks[] = [
'text' => $line,
'priority' => $priority,
'created' => date('Y-m-d H:i:s'),
'_status' => $status,
];
}
return $tasks;
}
function apply_import_tasks(string $username, string $list_id, array $parsed, bool $append): array {
$list = get_list($username, $list_id);
if (!$list) return ['added' => 0, 'error' => 'List not found'];
if (!$append) {
$list['active'] = [];
$list['completed'] = [];
}
$added = 0;
foreach ($parsed as $t) {
$task = [
'id' => bin2hex(random_bytes(6)),
'text' => $t['text'],
'priority' => $t['priority'],
'created' => $t['created'] ?? date('Y-m-d H:i:s'),
];
if (isset($t['order'])) $task['order'] = $t['order'];
if (isset($t['due'])) $task['due'] = $t['due'];
if (($t['_status'] ?? 'active') === 'completed') {
$task['completed'] = $t['completed_at'] ?? date('Y-m-d H:i:s');
$list['completed'][] = $task;
} else {
$list['active'][] = $task;
}
$added++;
}
$list['updated'] = date('Y-m-d H:i:s');
save_list($list);
return ['added' => $added, 'error' => null];
}
/* ─── URL helpers ─────────────────────────────────────────────────── */
function list_url(string $owner, string $list_id, array $extra = []): string {
// Prefer slug-based clean URL when no special params are needed
if (empty($extra)) {
$list = get_list($owner, $list_id);
if ($list && !empty($list['slug'])) {
return '@' . rawurlencode($owner) . '/' . rawurlencode($list['slug']);
}
}
// Fall back to query-string URL (used for ?edit=1, ?edit_task=X, etc.)
$params = array_merge(['owner' => $owner, 'id' => $list_id], $extra);
return 'list.php?' . http_build_query($params);
}
function redirect(string $url): never {
header('Location: ' . $url);
exit;
}
function redirect_back(string $fallback = 'dashboard.php'): never {
redirect($_SERVER['HTTP_REFERER'] ?? $fallback);
}