Files
protocol-droid/api/index.php
T
Protocolbot 3386ac6542 feat: switch from MySQL to SQLite for zero-dependency deployment
- Rewrite db.php to use SQLite via PDO (no external DB server needed)
- Rewrite schema.sql for SQLite (AUTOINCREMENT, CHECK constraints,
  triggers for updated_at, ON CONFLICT instead of ON DUPLICATE KEY)
- Fix config.php upsert to use ON CONFLICT syntax
- Fix index.php: tag filter via json_each, vote upsert via ON CONFLICT,
  and fix protocol create route (was returning 405 for POST /api/protocols)
- Fix yaml.php seed import: replace invalid false callbacks with fn() => null,
  fix yaml_parse pos argument (-1 -> 0)
- Update Dockerfile: drop pdo_mysql/mysqli (pdo_sqlite is built in)
- Update CloudronManifest.json: remove mysql addon, bump to v0.3.0
- Update README for SQLite deployment
2026-07-06 14:42:45 -06:00

517 lines
20 KiB
PHP

<?php
/**
* Protocol Droid API Router
* Handles all /api/* requests. Frontend is served from /frontend/.
*/
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
// CORS — same-origin only; do not reflect arbitrary origins
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$expected_host = $_SERVER['HTTP_HOST'] ?? '';
if ($origin) {
$origin_host = parse_url($origin, PHP_URL_HOST);
if ($origin_host === $expected_host) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Credentials: true');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/yaml.php';
// Auto-init database on first request
if (!defined('DB_INITIALIZED')) {
db_init();
define('DB_INITIALIZED', true);
}
// CSRF protection on all state-changing requests
check_csrf();
// --- Helpers ---
function json_body(): array {
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function respond(int $code, array $data): void {
http_response_code($code);
echo json_encode($data);
exit;
}
function parse_path(): array {
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH);
$path = preg_replace('#^/api#', '', $path);
$path = trim($path, '/');
return $path === '' ? [] : explode('/', $path);
}
function json_decode_field(?string $val): array {
if (!$val) return [];
$decoded = json_decode($val, true);
return is_array($decoded) ? $decoded : [];
}
function validate_visibility(?string $v): string {
if (!in_array($v, ['public', 'private', 'unlisted'], true)) return 'public';
return $v;
}
function sanitize_slug(string $s): string {
return preg_replace('/[^A-Za-z0-9._-]/', '', $s);
}
function format_protocol(array $row): array {
return [
'slug' => $row['slug'],
'title' => $row['title'],
'description' => $row['description'] ?? '',
'source' => $row['source'] ?? '',
'source_url' => $row['source_url'] ?? '',
'tags' => json_decode_field($row['tags'] ?? null),
'forked_from' => $row['forked_from'] ?? null,
'image' => $row['image'] ?? null,
'steps' => json_decode_field($row['steps'] ?? null),
'outcome' => $row['outcome'] ?? '',
'practice' => $row['practice'] ?? '',
'author' => $row['author_username'] ?? null,
'author_id' => $row['author_id'] ?? null,
'visibility' => $row['visibility'] ?? 'public',
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
function format_collection(array $row, array $items): array {
return [
'slug' => $row['slug'],
'title' => $row['title'],
'description' => $row['description'] ?? '',
'source' => $row['source'] ?? '',
'image' => $row['image'] ?? null,
'author' => $row['author_username'] ?? null,
'author_id' => $row['author_id'] ?? null,
'visibility' => $row['visibility'] ?? 'public',
'items' => array_map(fn($i) => [
'type' => $i['item_type'],
'slug' => $i['item_slug'],
], $items),
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
function format_collection_summary(array $row, int $item_count): array {
return [
'slug' => $row['slug'],
'title' => $row['title'],
'description' => $row['description'] ?? '',
'source' => $row['source'] ?? '',
'image' => $row['image'] ?? null,
'author' => $row['author_username'] ?? null,
'author_id' => $row['author_id'] ?? null,
'visibility' => $row['visibility'] ?? 'public',
'item_count' => $item_count,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
// --- Routing ---
$parts = parse_path();
$method = $_SERVER['REQUEST_METHOD'];
// Empty path = API root
if (empty($parts)) {
respond(200, [
'name' => 'Protocol Droid API',
'version' => '0.2.0',
'endpoints' => '/api/protocols, /api/collections, /api/auth/*, /api/users/*',
]);
}
// --- Auth routes ---
if ($parts[0] === 'auth') {
$action = $parts[1] ?? '';
if ($action === 'register' && $method === 'POST') handle_register();
elseif ($action === 'login' && $method === 'POST') handle_login();
elseif ($action === 'logout' && $method === 'POST') handle_logout();
elseif ($action === 'me' && $method === 'GET') handle_me();
elseif ($action === 'sso' && $method === 'POST') handle_sso_callback();
else respond(404, ['error' => 'Unknown auth endpoint']);
}
// --- Config ---
if ($parts[0] === 'config' && $method === 'GET') {
respond(200, merged_config());
}
// --- Protocols ---
if ($parts[0] === 'protocols') {
$slug = $parts[1] ?? null;
if (!$slug) {
// List protocols
if ($method === 'GET') {
$where = "WHERE p.visibility = 'public'";
$params = [];
$tag = $_GET['tag'] ?? null;
if ($tag) {
// Match tag within the JSON array string
$where .= " AND EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?)";
$params[] = $tag;
}
$q = $_GET['q'] ?? null;
if ($q) {
$where .= " AND (p.title LIKE ? OR p.description LIKE ? OR p.source LIKE ?)";
$params = array_merge($params, ["%$q%", "%$q%", "%$q%"]);
}
$sql = "SELECT p.*, u.username AS author_username
FROM protocols p LEFT JOIN users u ON p.author_id = u.id
$where ORDER BY p.created_at DESC";
$rows = db_all($sql, $params);
respond(200, array_map('format_protocol', $rows));
}
// Create protocol
if ($method === 'POST') {
$user = require_member();
$data = json_body();
$new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled'));
$id = db_insert('protocols', [
'slug' => $new_slug,
'title' => substr($data['title'] ?? '', 0, 255),
'description' => $data['description'] ?? '',
'source' => $data['source'] ?? ('@' . $user['username']),
'source_url' => substr($data['source_url'] ?? '', 0, 500),
'tags' => json_encode(array_slice($data['tags'] ?? [], 0, 20)),
'forked_from' => $data['forked_from'] ?? null,
'image' => $data['image'] ?? null,
'steps' => json_encode(array_slice($data['steps'] ?? [], 0, 50)),
'outcome' => $data['outcome'] ?? '',
'practice' => $data['practice'] ?? '',
'author_id' => $user['id'],
'visibility' => validate_visibility($data['visibility'] ?? 'public'),
]);
$row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", [$id]);
respond(201, format_protocol($row));
}
respond(405, ['error' => 'Method not allowed']);
}
// YAML export
if (($parts[2] ?? '') === 'yaml' && $method === 'GET') {
$row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Protocol not found']);
$safe_slug = sanitize_slug($slug);
header('Content-Type: text/yaml');
header('Content-Disposition: attachment; filename="' . $safe_slug . '.yaml"');
echo protocol_to_yaml($row);
exit;
}
// Votes for a protocol
if (($parts[2] ?? '') === 'votes' && $method === 'GET') {
// Check protocol exists and is public (or user is author/admin)
$row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Protocol not found']);
$user = current_user();
if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) {
respond(404, ['error' => 'Protocol not found']);
}
$votes = db_all(
"SELECT v.value, v.comment, v.created_at, u.username FROM votes v LEFT JOIN users u ON v.user_id = u.id WHERE v.protocol_slug = ?",
[$slug]
);
$score = array_sum(array_column($votes, 'value'));
respond(200, ['score' => $score, 'votes' => $votes]);
}
// Single protocol — enforce visibility
if ($method === 'GET') {
$row = db_one(
"SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?",
[$slug]
);
if (!$row) respond(404, ['error' => 'Protocol not found']);
$user = current_user();
if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) {
respond(404, ['error' => 'Protocol not found']);
}
respond(200, format_protocol($row));
}
// Update protocol — auth before existence check
if ($method === 'PUT') {
$user = require_auth();
$row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Protocol not found']);
if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') {
respond(403, ['error' => 'You can only edit your own protocols']);
}
$data = json_body();
$updates = [];
foreach (['title', 'description', 'source', 'source_url', 'forked_from', 'image', 'outcome', 'practice'] as $field) {
if (array_key_exists($field, $data)) $updates[$field] = $data[$field];
}
if (array_key_exists('tags', $data)) $updates['tags'] = json_encode(array_slice($data['tags'] ?? [], 0, 20));
if (array_key_exists('steps', $data)) $updates['steps'] = json_encode(array_slice($data['steps'] ?? [], 0, 50));
if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']);
if ($updates) {
db_update('protocols', $updates, 'slug = ?', [$slug]);
}
$row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?", [$slug]);
respond(200, format_protocol($row));
}
// Delete protocol — auth before existence check
if ($method === 'DELETE') {
$user = require_auth();
$row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Protocol not found']);
if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') {
respond(403, ['error' => 'You can only delete your own protocols']);
}
// Clean up orphaned votes and collection items
db_query("DELETE FROM votes WHERE protocol_slug = ?", [$slug]);
db_query("DELETE FROM collection_items WHERE item_type = 'protocol' AND item_slug = ?", [$slug]);
db_query("DELETE FROM protocols WHERE slug = ?", [$slug]);
respond(200, ['ok' => true]);
}
respond(405, ['error' => 'Method not allowed']);
}
// --- Collections ---
if ($parts[0] === 'collections') {
$slug = $parts[1] ?? null;
if (!$slug) {
// List collections
if ($method === 'GET') {
$rows = db_all(
"SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.visibility = 'public' ORDER BY c.created_at DESC"
);
$result = [];
foreach ($rows as $row) {
$items = db_all("SELECT * FROM collection_items WHERE collection_id = ?", [$row['id']]);
$result[] = format_collection_summary($row, count($items));
}
respond(200, $result);
}
// Create collection
if ($method === 'POST') {
$user = require_member();
$data = json_body();
$new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled'));
$id = db_insert('collections', [
'slug' => $new_slug,
'title' => substr($data['title'] ?? '', 0, 255),
'description' => $data['description'] ?? '',
'source' => $data['source'] ?? ('@' . $user['username']),
'image' => $data['image'] ?? null,
'author_id' => $user['id'],
'visibility' => validate_visibility($data['visibility'] ?? 'public'),
]);
$position = 0;
foreach (($data['items'] ?? []) as $item) {
db_insert('collection_items', [
'collection_id' => $id,
'item_type' => $item['type'] ?? 'protocol',
'item_slug' => $item['slug'] ?? '',
'position' => $position++,
]);
}
$row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.id = ?", [$id]);
$items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$id]);
respond(201, format_collection($row, $items));
}
respond(405, ['error' => 'Method not allowed']);
}
// Single collection — enforce visibility
if ($method === 'GET') {
$row = db_one(
"SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?",
[$slug]
);
if (!$row) respond(404, ['error' => 'Collection not found']);
$user = current_user();
if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) {
respond(404, ['error' => 'Collection not found']);
}
$items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]);
respond(200, format_collection($row, $items));
}
// Update collection — auth before existence check
if ($method === 'PUT') {
$user = require_auth();
$row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Collection not found']);
if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') {
respond(403, ['error' => 'You can only edit your own collections']);
}
$data = json_body();
$updates = [];
foreach (['title', 'description', 'source', 'image'] as $field) {
if (array_key_exists($field, $data)) $updates[$field] = $data[$field];
}
if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']);
if ($updates) db_update('collections', $updates, 'slug = ?', [$slug]);
// Update items if provided
if (isset($data['items'])) {
$col_id = $row['id'];
db_query("DELETE FROM collection_items WHERE collection_id = ?", [$col_id]);
$position = 0;
foreach ($data['items'] as $item) {
db_insert('collection_items', [
'collection_id' => $col_id,
'item_type' => $item['type'] ?? 'protocol',
'item_slug' => $item['slug'] ?? '',
'position' => $position++,
]);
}
}
$row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?", [$slug]);
$items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]);
respond(200, format_collection($row, $items));
}
// Delete collection — auth before existence check
if ($method === 'DELETE') {
$user = require_auth();
$row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]);
if (!$row) respond(404, ['error' => 'Collection not found']);
if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') {
respond(403, ['error' => 'You can only delete your own collections']);
}
db_query("DELETE FROM collection_items WHERE collection_id = ?", [$row['id']]);
db_query("DELETE FROM collections WHERE slug = ?", [$slug]);
respond(200, ['ok' => true]);
}
respond(405, ['error' => 'Method not allowed']);
}
// --- Users ---
if ($parts[0] === 'users') {
$username = $parts[1] ?? null;
if (!$username || $method !== 'GET') respond(404, ['error' => 'Unknown endpoint']);
$user = db_one("SELECT id, username, display_name, bio, created_at FROM users WHERE username = ?", [$username]);
if (!$user) respond(404, ['error' => 'User not found']);
// Fetch full protocol data for user's public protocols
$protocols = db_all(
"SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.author_id = ? AND p.visibility = 'public' ORDER BY p.created_at DESC",
[$user['id']]
);
$collections = db_all("SELECT slug, title, description FROM collections WHERE author_id = ? AND visibility = 'public' ORDER BY created_at DESC", [$user['id']]);
respond(200, [
'user' => $user,
'protocols' => array_map('format_protocol', $protocols),
'collections' => $collections,
]);
}
// --- Votes ---
if ($parts[0] === 'votes' && $method === 'POST') {
$user = require_member();
$data = json_body();
$protocol_slug = $data['protocol_slug'] ?? '';
// Constrain value to -1, 0, or 1
$value = (int)($data['value'] ?? 1);
$value = $value > 0 ? 1 : ($value < 0 ? -1 : 0);
$comment = substr($data['comment'] ?? '', 0, 1000);
// Check protocol exists and is public
$proto = db_one("SELECT * FROM protocols WHERE slug = ?", [$protocol_slug]);
if (!$proto) respond(404, ['error' => 'Protocol not found']);
if ($proto['visibility'] !== 'public') respond(404, ['error' => 'Protocol not found']);
// Upsert vote
db_query(
"INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, protocol_slug) DO UPDATE SET value = excluded.value, comment = excluded.comment",
[$user['id'], $protocol_slug, $value, $comment]
);
respond(200, ['ok' => true]);
}
// --- Import / Export / Seed ---
if ($parts[0] === 'export' && $method === 'GET') {
require_admin();
$protocols = db_all("SELECT * FROM protocols WHERE visibility = 'public'");
$output = "# Protocol Droid Export\n\n";
foreach ($protocols as $p) {
$output .= "---\n\n" . protocol_to_yaml($p) . "\n";
}
header('Content-Type: text/yaml');
header('Content-Disposition: attachment; filename="protocol-droid-export.yaml"');
echo $output;
exit;
}
if ($parts[0] === 'import' && $method === 'POST') {
require_admin();
$data = json_body();
$yaml_text = $data['yaml'] ?? '';
$parsed = parse_protocol_yaml($yaml_text);
if (!$parsed) respond(400, ['error' => 'Could not parse YAML']);
$parsed['author_id'] = current_user()['id'];
$parsed['slug'] = ensure_unique_slug($parsed['slug']);
$parsed['visibility'] = validate_visibility($parsed['visibility'] ?? 'public');
$id = db_insert('protocols', $parsed);
respond(201, ['id' => $id, 'slug' => $parsed['slug']]);
}
if ($parts[0] === 'seed' && $method === 'POST') {
require_admin();
$seeds_dir = __DIR__ . '/../seeds';
if (!is_dir($seeds_dir)) respond(404, ['error' => 'No seeds directory']);
$count = 0;
foreach (glob($seeds_dir . '/*.yaml') as $file) {
$yaml_text = file_get_contents($file);
$parsed = parse_protocol_yaml($yaml_text);
if (!$parsed) continue;
if (db_one("SELECT slug FROM protocols WHERE slug = ?", [$parsed['slug']])) continue;
db_insert('protocols', $parsed);
$count++;
}
respond(200, ['imported' => $count]);
}
respond(404, ['error' => 'Unknown endpoint: /' . implode('/', $parts)]);