c391feb450
I also updated [api.php](/Users/tyemeclifford/Documents/GH/blog/api.php) so content.save accepts markdown, content, or body for post/page content.
503 lines
16 KiB
PHP
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';
|
|
}
|