3386ac6542
- 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
517 lines
20 KiB
PHP
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)]); |