Files
comv3/api.php
T
Ty Clifford 6ab3058a92 v3
2026-05-20 21:08:23 -04:00

359 lines
14 KiB
PHP

<?php
/**
* api.php — REST API for updates.json
* ─────────────────────────────────────────────────────────────────────────────
* Drop alongside index.php. The JSON store and API key path are configured
* in the CONFIG block below.
*
* Authentication
* Pass the key in the X-API-Key header or the ?api_key= query parameter.
*
* Endpoints
* GET api.php List posts (?limit=N &offset=N &tag=foo)
* GET api.php?id=<id> Get one post
* POST api.php Create post (JSON body)
* PUT api.php?id=<id> Update post (JSON body, partial)
* DELETE api.php?id=<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);