359 lines
14 KiB
PHP
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);
|