Files
tcms/api.php
T
2026-07-05 17:15:33 -04:00

503 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use NeonBlog\ContentRepository;
use NeonBlog\View;
$app = require __DIR__ . '/app/bootstrap.php';
$repo = $app->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(),
'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<string, mixed> */
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<string, mixed> $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<string, mixed> $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 or TCMS_API_PASSWORD_HASH.', 503);
}
if (!tcms_is_authenticated($config)) {
tcms_error('API authentication failed. Use the username and password from the site .env file.', 401);
}
}
function tcms_is_authenticated($config): bool
{
if (!tcms_credentials_configured()) {
return false;
}
$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_username() !== ''
&& (tcms_configured_password() !== '' || tcms_configured_password_hash() !== '');
}
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);
}
/** @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<string, mixed> $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);
if (array_key_exists('markdown', $payload)) {
$markdown = (string) $payload['markdown'];
} elseif (array_key_exists('content', $payload)) {
$markdown = (string) $payload['content'];
} elseif (array_key_exists('body', $payload)) {
$markdown = (string) $payload['body'];
} else {
$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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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';
}