repository(); $comments = $app->comments(); $config = $app->config(); tcms_load_env(BLOG_ROOT . '/.env'); 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), 'auth_required' => true, 'credentials_configured' => tcms_credentials_configured(), 'token_configured' => tcms_configured_token() !== '', 'capabilities' => [ 'content' => ['list', 'get', 'save', 'delete'], 'media' => ['list', 'upload'], 'comments' => ['list', 'status', 'delete'], ], ]); } if (!tcms_api_enabled($config)) { 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; } function tcms_load_env(string $path): void { if (!is_file($path)) { return; } foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { continue; } [$key, $value] = explode('=', $line, 2); $key = trim($key); $value = trim($value); if ($key === '' || !preg_match('/^[A-Z0-9_]+$/', $key)) { continue; } if ( (str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'")) ) { $value = substr($value, 1, -1); } if (getenv($key) === false) { putenv($key . '=' . $value); } $_ENV[$key] = $value; $_SERVER[$key] ??= $value; } } /** @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_credentials_configured()) { tcms_error('API credentials are not configured. Create a site .env file with TCMS_API_USERNAME and TCMS_API_PASSWORD, TCMS_API_PASSWORD_HASH, or TCMS_API_TOKEN.', 503); } if (!tcms_is_authenticated($config)) { tcms_error('API authentication failed. Use HTTP Basic credentials from .env or send TCMS_API_TOKEN as a Bearer token or X-TCMS-Token header.', 401); } } function tcms_is_authenticated($config): bool { if (!tcms_credentials_configured()) { return false; } $configuredToken = tcms_configured_token(); $providedToken = tcms_request_token(); if ($configuredToken !== '' && $providedToken !== '' && hash_equals($configuredToken, $providedToken)) { return true; } $basic = tcms_request_basic_credentials(); if ($basic === null) { return false; } [$username, $password] = $basic; return tcms_username_matches($username) && tcms_password_matches($password); } function tcms_api_enabled($config): bool { $envValue = tcms_env('TCMS_API_ENABLED'); if ($envValue !== '') { return filter_var($envValue, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; } return (bool) $config->get('api.enabled', true); } function tcms_credentials_configured(): bool { return tcms_configured_token() !== '' || (tcms_configured_username() !== '' && (tcms_configured_password() !== '' || tcms_configured_password_hash() !== '')); } function tcms_configured_token(): string { return tcms_env('TCMS_API_TOKEN'); } function tcms_configured_username(): string { return tcms_env('TCMS_API_USERNAME'); } function tcms_configured_password(): string { return tcms_env('TCMS_API_PASSWORD'); } function tcms_configured_password_hash(): string { return tcms_env('TCMS_API_PASSWORD_HASH'); } function tcms_env(string $key): string { $value = getenv($key); if ($value === false) { $value = $_ENV[$key] ?? $_SERVER[$key] ?? ''; } return trim((string) $value); } 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'] ?? '')); } /** @return array{0: string, 1: string}|null */ function tcms_request_basic_credentials(): ?array { if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { return [(string) $_SERVER['PHP_AUTH_USER'], (string) $_SERVER['PHP_AUTH_PW']]; } $headers = function_exists('getallheaders') ? getallheaders() : []; $auth = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $headers['Authorization'] ?? $headers['authorization'] ?? ''); if (!preg_match('/^Basic\s+(.+)$/i', $auth, $matches)) { return null; } $decoded = base64_decode(trim($matches[1]), true); if (!is_string($decoded) || !str_contains($decoded, ':')) { return null; } [$username, $password] = explode(':', $decoded, 2); return [$username, $password]; } function tcms_username_matches(string $username): bool { $configured = tcms_configured_username(); return $configured !== '' && hash_equals($configured, $username); } function tcms_password_matches(string $password): bool { $hash = tcms_configured_password_hash(); if ($hash !== '') { return password_verify($password, $hash); } $configured = tcms_configured_password(); return $configured !== '' && hash_equals($configured, $password); } 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'; }