repository(); $comments = $app->comments(); $config = $app->config(); header('Content-Type: application/json; charset=utf-8'); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { tcms_json(['ok' => true]); } $payload = tcms_payload(); $action = (string) ($_GET['action'] ?? $payload['action'] ?? ''); try { if ($action === '' || $action === 'ping') { tcms_json([ 'ok' => true, 'name' => 'Ty Clifford Content Management System API', 'site' => $config->get('site', []), 'authenticated' => tcms_is_authenticated($config), 'token_configured' => tcms_configured_token($config) !== '', 'capabilities' => [ 'content' => ['list', 'get', 'save', 'delete'], 'media' => ['list', 'upload'], 'comments' => ['list', 'status', 'delete'], ], ]); } if (!$config->get('api.enabled', true)) { tcms_error('The API is disabled for this TCMS site.', 403); } tcms_require_auth($config); match ($action) { 'content.list' => tcms_content_list($repo), 'content.get' => tcms_content_get($repo), 'content.save' => tcms_content_save($repo, $payload), 'content.delete' => tcms_content_delete($repo, $payload), 'media.list' => tcms_media_list(), 'media.upload' => tcms_media_upload($payload), 'comments.list' => tcms_comments_list($comments), 'comments.status' => tcms_comments_status($comments, $payload), 'comments.delete' => tcms_comments_delete($comments, $payload), default => tcms_error('Unknown API action: ' . $action, 404), }; } catch (Throwable $error) { tcms_error($error->getMessage(), 500); } /** @return array */ function tcms_payload(): array { $contentType = (string) ($_SERVER['CONTENT_TYPE'] ?? ''); if (str_contains($contentType, 'application/json')) { $decoded = json_decode((string) file_get_contents('php://input'), true); return is_array($decoded) ? $decoded : []; } return $_POST; } /** @param array $data */ function tcms_json(array $data, int $status = 200): never { http_response_code($status); echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; exit; } /** @param array $extra */ function tcms_error(string $message, int $status = 400, array $extra = []): never { tcms_json(['ok' => false, 'error' => $message] + $extra, $status); } function tcms_require_auth($config): void { if (!tcms_is_authenticated($config)) { tcms_error('API authentication failed. Send the configured token as a Bearer token or X-TCMS-Token header.', 401); } } function tcms_is_authenticated($config): bool { $configured = tcms_configured_token($config); if ($configured === '') { return (bool) $config->get('api.allow_local_without_token', true) && tcms_is_local_request(); } $provided = tcms_request_token(); return $provided !== '' && hash_equals($configured, $provided); } function tcms_configured_token($config): string { $envToken = trim((string) getenv('TCMS_API_TOKEN')); return $envToken !== '' ? $envToken : trim((string) $config->get('api.token', '')); } function tcms_request_token(): string { $headers = function_exists('getallheaders') ? getallheaders() : []; $auth = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $headers['Authorization'] ?? $headers['authorization'] ?? ''); if (preg_match('/^Bearer\s+(.+)$/i', $auth, $matches)) { return trim($matches[1]); } return trim((string) ($_SERVER['HTTP_X_TCMS_TOKEN'] ?? $headers['X-TCMS-Token'] ?? $headers['x-tcms-token'] ?? '')); } function tcms_is_local_request(): bool { $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); return in_array($remote, ['127.0.0.1', '::1', 'localhost', ''], true); } function tcms_content_list(ContentRepository $repo): never { $includeDrafts = filter_var($_GET['include_drafts'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); $includeDrafts = $includeDrafts ?? true; $type = (string) ($_GET['type'] ?? 'all'); $items = match ($type) { 'post', 'posts' => $repo->posts($includeDrafts), 'page', 'pages' => $repo->pages($includeDrafts), default => $repo->all($includeDrafts), }; tcms_json([ 'ok' => true, 'items' => array_map(static fn (array $item): array => tcms_item($item, false), $items), ]); } function tcms_content_get(ContentRepository $repo): never { $slug = (string) ($_GET['slug'] ?? ''); if ($slug === '') { tcms_error('A slug is required.'); } $item = $repo->find($slug, true); if ($item === null) { tcms_error('Content not found: ' . $slug, 404); } tcms_json(['ok' => true, 'item' => tcms_item($item, true)]); } /** @param array $payload */ function tcms_content_save(ContentRepository $repo, array $payload): never { $metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; $slug = (string) ($payload['slug'] ?? $metadata['slug'] ?? ''); $originalSlug = (string) ($payload['original_slug'] ?? $payload['originalSlug'] ?? ''); $existing = $originalSlug !== '' ? $repo->find($originalSlug, true) : ($slug !== '' ? $repo->find($slug, true) : null); $markdown = array_key_exists('markdown', $payload) ? (string) $payload['markdown'] : (string) ($existing['markdown'] ?? ''); $allowed = [ 'title', 'slug', 'type', 'status', 'date', 'category', 'tags', 'summary', 'author', 'cover', 'allow_comments', 'menu_order', ]; $clean = []; foreach ($allowed as $key) { if (array_key_exists($key, $metadata)) { $clean[$key] = $metadata[$key]; } } if (isset($payload['status'])) { $clean['status'] = (string) $payload['status']; } if (isset($clean['tags'])) { $clean['tags'] = ContentRepository::normalizeList($clean['tags']); } $slug = (string) ($clean['slug'] ?? $slug); $saved = $repo->save($slug, $clean, $markdown); if ($originalSlug !== '' && $originalSlug !== $saved['slug']) { $repo->delete($originalSlug); } tcms_json(['ok' => true, 'item' => tcms_item($saved, true)]); } /** @param array $payload */ function tcms_content_delete(ContentRepository $repo, array $payload): never { $slug = (string) ($_GET['slug'] ?? $payload['slug'] ?? ''); if ($slug === '') { tcms_error('A slug is required.'); } tcms_json(['ok' => $repo->delete($slug)]); } /** @param array $item */ function tcms_item(array $item, bool $includeBody): array { $record = [ 'slug' => (string) ($item['slug'] ?? ''), 'title' => (string) ($item['title'] ?? ''), 'type' => (string) ($item['type'] ?? 'post'), 'status' => (string) ($item['status'] ?? 'draft'), 'date' => (string) ($item['date'] ?? ''), 'modified' => (string) ($item['modified'] ?? ''), 'category' => (string) ($item['category'] ?? ''), 'tags' => array_values((array) ($item['tags'] ?? [])), 'summary' => (string) ($item['summary'] ?? ''), 'author' => (string) ($item['author'] ?? ''), 'cover' => (string) ($item['cover'] ?? ''), 'allow_comments' => (bool) ($item['allow_comments'] ?? true), 'menu_order' => (int) ($item['menu_order'] ?? 0), 'url' => View::itemUrl($item), 'excerpt' => (string) ($item['excerpt'] ?? ''), ]; if ($includeBody) { $record['markdown'] = (string) ($item['markdown'] ?? ''); } return $record; } function tcms_media_list(): never { $root = BLOG_ROOT . '/bl-content/uploads'; $items = []; if (is_dir($root)) { $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { if (!$file->isFile() || str_starts_with($file->getFilename(), '.')) { continue; } $relative = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($root))), '/'); $items[] = [ 'name' => $file->getFilename(), 'path' => 'bl-content/uploads/' . $relative, 'url' => View::url('bl-content/uploads/' . $relative), 'size' => $file->getSize(), 'modified' => date('c', $file->getMTime()), 'mime' => tcms_mime($file->getPathname()), ]; } } usort($items, static fn (array $a, array $b): int => strcmp((string) $b['modified'], (string) $a['modified'])); tcms_json(['ok' => true, 'items' => $items]); } /** @param array $payload */ function tcms_media_upload(array $payload): never { $folder = tcms_safe_folder((string) ($_POST['folder'] ?? $payload['folder'] ?? '')); $uploads = BLOG_ROOT . '/bl-content/uploads'; $targetDir = $uploads . ($folder !== '' ? '/' . $folder : ''); if (!is_dir($targetDir)) { mkdir($targetDir, 0775, true); } if (isset($_FILES['file']) && is_array($_FILES['file'])) { $upload = $_FILES['file']; if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { tcms_error('Upload failed with code ' . (int) $upload['error']); } $name = tcms_safe_filename((string) ($upload['name'] ?? 'upload.bin')); $target = tcms_unique_media_path($targetDir . '/' . $name); if (!move_uploaded_file((string) $upload['tmp_name'], $target)) { tcms_error('Unable to store uploaded file.', 500); } } else { $name = tcms_safe_filename((string) ($payload['filename'] ?? 'upload.bin')); $data = base64_decode((string) ($payload['data_base64'] ?? ''), true); if ($data === false) { tcms_error('A multipart file or base64 payload is required.'); } $target = tcms_unique_media_path($targetDir . '/' . $name); file_put_contents($target, $data); } $relative = ltrim(str_replace('\\', '/', substr($target, strlen($uploads))), '/'); tcms_json([ 'ok' => true, 'item' => [ 'name' => basename($target), 'path' => 'bl-content/uploads/' . $relative, 'url' => View::url('bl-content/uploads/' . $relative), 'size' => filesize($target) ?: 0, 'modified' => date('c', filemtime($target) ?: time()), 'mime' => tcms_mime($target), ], ]); } function tcms_comments_list($comments): never { $status = (string) ($_GET['status'] ?? ''); $slug = (string) ($_GET['slug'] ?? ''); tcms_json([ 'ok' => true, 'items' => $comments->list($status !== '' ? $status : null, $slug !== '' ? $slug : null), ]); } /** @param array $payload */ function tcms_comments_status($comments, array $payload): never { $id = (int) ($_GET['id'] ?? $payload['id'] ?? 0); $status = (string) ($_GET['status'] ?? $payload['status'] ?? ''); if ($id < 1 || $status === '') { tcms_error('A comment id and status are required.'); } tcms_json(['ok' => $comments->setStatus($id, $status)]); } /** @param array $payload */ function tcms_comments_delete($comments, array $payload): never { $id = (int) ($_GET['id'] ?? $payload['id'] ?? 0); if ($id < 1) { tcms_error('A comment id is required.'); } tcms_json(['ok' => $comments->delete($id)]); } function tcms_safe_folder(string $folder): string { $folder = trim(str_replace('\\', '/', $folder), '/'); if ($folder === '') { return ''; } $segments = []; foreach (explode('/', $folder) as $segment) { $segment = trim(preg_replace('/[^A-Za-z0-9._-]+/', '-', $segment) ?? '', '.-'); if ($segment !== '') { $segments[] = $segment; } } return implode('/', $segments); } function tcms_safe_filename(string $name): string { $name = basename(str_replace('\\', '/', $name)); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $blocked = ['php', 'phtml', 'phar', 'cgi', 'pl', 'asp', 'aspx', 'jsp', 'sh']; if ($extension === '' || in_array($extension, $blocked, true)) { tcms_error('That file type is not allowed for media uploads.'); } $base = pathinfo($name, PATHINFO_FILENAME); $base = trim(preg_replace('/[^A-Za-z0-9._-]+/', '-', $base) ?? 'upload', '.-'); return ($base !== '' ? $base : 'upload') . '.' . $extension; } function tcms_unique_media_path(string $path): string { if (!file_exists($path)) { return $path; } $dir = dirname($path); $name = pathinfo($path, PATHINFO_FILENAME); $extension = pathinfo($path, PATHINFO_EXTENSION); for ($i = 2; $i < 10000; $i++) { $candidate = $dir . '/' . $name . '-' . $i . '.' . $extension; if (!file_exists($candidate)) { return $candidate; } } tcms_error('Unable to find an available filename.', 500); } function tcms_mime(string $path): string { if (class_exists('finfo')) { $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($path); if (is_string($mime) && $mime !== '') { return $mime; } } return 'application/octet-stream'; }