Get one post * POST api.php Create post (JSON body) * PUT api.php?id= Update post (JSON body, partial) * DELETE api.php?id= Delete post * POST api.php?action=reorder Reorder (JSON body: {"ids":["003","001",…]}) * POST api.php?action=bulk_delete Bulk delete (JSON body: {"ids":["001","002"]}) * * Post schema * id string auto-generated if omitted on create * text string required * date string ISO-8601; defaults to now() on create * tags string[] optional * link string optional URL * link_label string optional */ // ══════════════════════════════════════════════════════════════════════════════ // CONFIG — edit this block only // ══════════════════════════════════════════════════════════════════════════════ // Path to updates.json (relative to this file or absolute) define('API_JSON_PATH', __DIR__ . '/index_config/updates.json'); // Path to api_key.txt — one key per line; lines starting with # are ignored. // Generate a key: php -r "echo bin2hex(random_bytes(32));" define('API_KEY_FILE', __DIR__ . '/index_config/api_keys.txt'); // Allowed CORS origin. '*' opens to all; set to 'https://tyclifford.com' to lock. define('API_CORS_ORIGIN', 'https:/tyclifford.com'); // Maximum posts returned by a single list request (hard ceiling). define('API_MAX_LIMIT', 100); // ══════════════════════════════════════════════════════════════════════════════ // BOOTSTRAP // ══════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); if (API_CORS_ORIGIN !== '') { header('Access-Control-Allow-Origin: ' . API_CORS_ORIGIN); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: X-API-Key, Content-Type'); } // Handle CORS preflight if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } // ── Helpers ─────────────────────────────────────────────────────────────────── function api_ok(mixed $data, int $code = 200): never { http_response_code($code); echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); exit; } function api_err(string $message, int $code = 400): never { http_response_code($code); echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE); exit; } // ── Auth ────────────────────────────────────────────────────────────────────── function api_auth(): void { if (!file_exists(API_KEY_FILE)) { api_err('API key file not configured. Create index_config/api_keys.txt with at least one key.', 503); } $supplied = trim( $_SERVER['HTTP_X_API_KEY'] ?? $_SERVER['HTTP_X_Api_Key'] ?? ($_GET['api_key'] ?? '') ); if ($supplied === '') { api_err('Missing API key. Supply X-API-Key header or ?api_key= parameter.', 401); } $valid_keys = array_filter( array_map('trim', file(API_KEY_FILE)), fn($line) => $line !== '' && !str_starts_with($line, '#') ); foreach ($valid_keys as $key) { if (hash_equals($key, $supplied)) return; // constant-time compare } api_err('Invalid API key.', 403); } // ── JSON store ──────────────────────────────────────────────────────────────── function load_posts(): array { if (!file_exists(API_JSON_PATH)) return []; $raw = file_get_contents(API_JSON_PATH); $data = json_decode($raw, true); return is_array($data) ? $data : []; } function save_posts(array $posts): void { $dir = dirname(API_JSON_PATH); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true)) { api_err('Cannot create data directory.', 500); } } $json = json_encode( array_values($posts), // reindex JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ); // Atomic write with exclusive lock $tmp = API_JSON_PATH . '.tmp.' . getmypid(); if (file_put_contents($tmp, $json, LOCK_EX) === false) { api_err('Failed to write data file.', 500); } if (!rename($tmp, API_JSON_PATH)) { @unlink($tmp); api_err('Failed to replace data file.', 500); } } function find_post(array $posts, string $id): int|false { foreach ($posts as $i => $p) { if (($p['id'] ?? '') === $id) return $i; } return false; } function generate_id(array $posts): string { $existing = array_column($posts, 'id'); do { $id = strtolower(bin2hex(random_bytes(4))); } while (in_array($id, $existing, true)); return $id; } function validate_post(array $input, bool $require_text = true): array { $errors = []; if ($require_text && trim($input['text'] ?? '') === '') { $errors[] = '"text" is required and cannot be empty.'; } if (isset($input['text']) && mb_strlen($input['text']) > 4000) { $errors[] = '"text" exceeds 4000 characters.'; } if (isset($input['date'])) { $dt = DateTime::createFromFormat(DateTime::ATOM, $input['date']) ?: DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $input['date']) ?: DateTime::createFromFormat('Y-m-d', $input['date']); if ($dt === false) { $errors[] = '"date" must be ISO-8601 (e.g. 2025-08-14T21:30:00Z).'; } } if (isset($input['tags'])) { if (!is_array($input['tags'])) { $errors[] = '"tags" must be an array of strings.'; } else { foreach ($input['tags'] as $t) { if (!is_string($t) || mb_strlen($t) > 50) { $errors[] = 'Each tag must be a string ≤ 50 characters.'; break; } } } } if (isset($input['link']) && $input['link'] !== '') { if (!filter_var($input['link'], FILTER_VALIDATE_URL)) { $errors[] = '"link" must be a valid URL or empty string.'; } } return $errors; } function sanitise_post(array $input): array { $out = []; if (isset($input['text'])) $out['text'] = trim((string)$input['text']); if (isset($input['date'])) $out['date'] = trim((string)$input['date']); if (isset($input['tags'])) $out['tags'] = array_values(array_map('trim', (array)$input['tags'])); if (array_key_exists('link', $input)) $out['link'] = trim((string)$input['link']); if (array_key_exists('link_label', $input)) $out['link_label'] = trim((string)$input['link_label']); return $out; } // ── Request body ────────────────────────────────────────────────────────────── function read_body(): array { $raw = file_get_contents('php://input'); if ($raw === '' || $raw === false) return []; $data = json_decode($raw, true); if (!is_array($data)) api_err('Request body must be valid JSON.', 400); return $data; } // ══════════════════════════════════════════════════════════════════════════════ // ROUTING // ══════════════════════════════════════════════════════════════════════════════ api_auth(); $method = $_SERVER['REQUEST_METHOD']; $id = trim($_GET['id'] ?? ''); $action = trim($_GET['action'] ?? ''); // ── POST ?action=reorder ────────────────────────────────────────────────────── if ($method === 'POST' && $action === 'reorder') { $body = read_body(); if (!isset($body['ids']) || !is_array($body['ids'])) { api_err('"ids" array is required.'); } $posts = load_posts(); $indexed = []; foreach ($posts as $p) $indexed[$p['id']] = $p; $reordered = []; foreach ($body['ids'] as $eid) { if (!is_string($eid) || !isset($indexed[$eid])) { api_err("Unknown id \"$eid\" in reorder list."); } $reordered[] = $indexed[$eid]; unset($indexed[$eid]); } // Append any posts not mentioned in the ids list at the end foreach ($indexed as $p) $reordered[] = $p; save_posts($reordered); api_ok(['reordered' => count($reordered)]); } // ── POST ?action=bulk_delete ────────────────────────────────────────────────── if ($method === 'POST' && $action === 'bulk_delete') { $body = read_body(); if (!isset($body['ids']) || !is_array($body['ids'])) { api_err('"ids" array is required.'); } $posts = load_posts(); $del_set = array_flip($body['ids']); $kept = array_values(array_filter($posts, fn($p) => !isset($del_set[$p['id'] ?? '']))); $deleted = count($posts) - count($kept); save_posts($kept); api_ok(['deleted' => $deleted]); } // ── GET — list or single ────────────────────────────────────────────────────── if ($method === 'GET') { $posts = load_posts(); if ($id !== '') { $idx = find_post($posts, $id); if ($idx === false) api_err("Post \"$id\" not found.", 404); api_ok($posts[$idx]); } // Filtering $tag = trim($_GET['tag'] ?? ''); if ($tag !== '') { $posts = array_values(array_filter($posts, fn($p) => in_array($tag, (array)($p['tags'] ?? []), true))); } // Pagination $total = count($posts); $limit = min(max(1, (int)($_GET['limit'] ?? $total)), API_MAX_LIMIT); $offset = max(0, (int)($_GET['offset'] ?? 0)); $page = array_slice($posts, $offset, $limit); api_ok([ 'total' => $total, 'limit' => $limit, 'offset' => $offset, 'items' => $page, ]); } // ── POST — create ───────────────────────────────────────────────────────────── if ($method === 'POST' && $action === '') { $body = read_body(); $errors = validate_post($body, require_text: true); if ($errors) api_err(implode(' ', $errors)); $posts = load_posts(); // Allow caller to supply id; generate one if missing or already taken $new_id = trim((string)($body['id'] ?? '')); if ($new_id === '' || find_post($posts, $new_id) !== false) { $new_id = generate_id($posts); } $safe = sanitise_post($body); $post = [ 'id' => $new_id, 'text' => $safe['text'], 'date' => $safe['date'] ?? (new DateTime('now', new DateTimeZone('UTC')))->format(DateTime::ATOM), 'tags' => $safe['tags'] ?? [], 'link' => $safe['link'] ?? '', 'link_label' => $safe['link_label'] ?? '', ]; array_unshift($posts, $post); // newest first save_posts($posts); api_ok($post, 201); } // ── PUT — update ────────────────────────────────────────────────────────────── if ($method === 'PUT') { if ($id === '') api_err('?id= is required for PUT.'); $posts = load_posts(); $idx = find_post($posts, $id); if ($idx === false) api_err("Post \"$id\" not found.", 404); $body = read_body(); $errors = validate_post($body, require_text: false); if ($errors) api_err(implode(' ', $errors)); $safe = sanitise_post($body); $posts[$idx] = array_merge($posts[$idx], $safe); $posts[$idx]['id'] = $id; // id is immutable save_posts($posts); api_ok($posts[$idx]); } // ── DELETE — single ─────────────────────────────────────────────────────────── if ($method === 'DELETE') { if ($id === '') api_err('?id= is required for DELETE.'); $posts = load_posts(); $idx = find_post($posts, $id); if ($idx === false) api_err("Post \"$id\" not found.", 404); $deleted = $posts[$idx]; array_splice($posts, $idx, 1); save_posts($posts); api_ok($deleted); } api_err('Method not allowed.', 405);