feat: initial release of Protocol Droid v0.2.0
A tool for authoring, sharing, and curating social protocols. Features: - Protocol library with search, tag filtering, and sort by date/relevance - Protocol authoring with structured fields (title, description, steps, outcome, practice, source, tags) - Protocol forking with provenance tracking - Collection creation with searchable protocol picker and ordering - User accounts with roles (admin, member, viewer) - YAML import/export for portability - Self-hostable on LAMP/Cloudron, works in subdirectories - Responsive design with hamburger menu on mobile - About page Security: - CSRF protection via Origin/Referer validation - Session regeneration on login/register - Secure session cookie params (HttpOnly, SameSite, Secure) - Visibility enforcement on private/unlisted items - YAML object injection hardening - Login rate limiting - Path traversal protection - Input validation and length clamping - Vote value constraining Stack: PHP 8.x + MySQL/MariaDB, vanilla JS frontend, no external dependencies. Hippocratic License (HL3-CORE).
This commit is contained in:
Executable
+223
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple session-based authentication.
|
||||
* Supports registration, login, SSO callback, and role checks.
|
||||
*/
|
||||
|
||||
// Set secure session cookie params before starting
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => !empty($_SERVER['HTTPS']) || getenv('FORCE_HTTPS'),
|
||||
]);
|
||||
session_start();
|
||||
|
||||
function current_user(): ?array {
|
||||
if (!isset($_SESSION['user_id'])) return null;
|
||||
$user = db_one("SELECT id, username, display_name, bio, role, created_at FROM users WHERE id = ?", [$_SESSION['user_id']]);
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
function require_auth(): array {
|
||||
$user = current_user();
|
||||
if (!$user) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
function require_admin(): array {
|
||||
$user = require_auth();
|
||||
if ($user['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
function require_member(): array {
|
||||
$user = require_auth();
|
||||
if ($user['role'] === 'viewer') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Member access required']);
|
||||
exit;
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
// --- CSRF protection ---
|
||||
function check_csrf(): void {
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if (!in_array($method, ['POST', 'PUT', 'DELETE'])) return;
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
$expected_host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if ($origin) {
|
||||
$origin_host = parse_url($origin, PHP_URL_HOST);
|
||||
if ($origin_host !== $expected_host) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Cross-origin requests are not allowed']);
|
||||
exit;
|
||||
}
|
||||
} elseif ($referer) {
|
||||
$referer_host = parse_url($referer, PHP_URL_HOST);
|
||||
if ($referer_host !== $expected_host) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Cross-origin requests are not allowed']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rate limiting for login ---
|
||||
function check_login_rate(string $username): void {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$key = 'login_attempts_' . md5($ip . $username);
|
||||
$file = sys_get_temp_dir() . '/' . $key;
|
||||
$attempts = file_exists($file) ? (int)file_get_contents($file) : 0;
|
||||
if ($attempts >= 10) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'Too many login attempts. Try again later.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function record_login_failure(string $username): void {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$key = 'login_attempts_' . md5($ip . $username);
|
||||
$file = sys_get_temp_dir() . '/' . $key;
|
||||
$attempts = file_exists($file) ? (int)file_get_contents($file) : 0;
|
||||
file_put_contents($file, ($attempts + 1));
|
||||
touch($file, time() + 900);
|
||||
}
|
||||
|
||||
function clear_login_rate(string $username): void {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$key = 'login_attempts_' . md5($ip . $username);
|
||||
$file = sys_get_temp_dir() . '/' . $key;
|
||||
if (file_exists($file)) unlink($file);
|
||||
}
|
||||
|
||||
function handle_register(): void {
|
||||
$data = json_body();
|
||||
$username = trim($data['username'] ?? '');
|
||||
$password = $data['password'] ?? '';
|
||||
$display_name = trim($data['display_name'] ?? '');
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $username)) {
|
||||
respond(400, ['error' => 'Username must be 3-32 characters: letters, numbers, underscore']);
|
||||
return;
|
||||
}
|
||||
if (strlen($password) < 8 || strlen($password) > 72) {
|
||||
respond(400, ['error' => 'Password must be 8-72 characters']);
|
||||
return;
|
||||
}
|
||||
if (db_one("SELECT id FROM users WHERE username = ?", [$username])) {
|
||||
respond(409, ['error' => 'Username already taken']);
|
||||
return;
|
||||
}
|
||||
|
||||
$user_count = (int) db_one("SELECT COUNT(*) as c FROM users")['c'];
|
||||
$role = 'member';
|
||||
|
||||
if ($user_count === 0) {
|
||||
$bootstrap_token = getenv('ADMIN_BOOTSTRAP_TOKEN');
|
||||
if ($bootstrap_token) {
|
||||
$provided = $data['bootstrap_token'] ?? '';
|
||||
if ($provided !== $bootstrap_token) {
|
||||
respond(403, ['error' => 'First registration requires an admin bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN env var and include it in the registration request.']);
|
||||
return;
|
||||
}
|
||||
$role = 'admin';
|
||||
} else {
|
||||
error_log('Protocol Droid: First user registered as admin without bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN for production.');
|
||||
$role = 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
if ($user_count > 0) {
|
||||
$reg_enabled = db_one("SELECT value FROM config WHERE `key` = 'registration_enabled'");
|
||||
if ($reg_enabled && $reg_enabled['value'] !== 'true') {
|
||||
respond(403, ['error' => 'Registration is disabled on this instance']);
|
||||
return;
|
||||
}
|
||||
$require_approval = config_get('require_approval', 'false') === 'true';
|
||||
if ($require_approval) {
|
||||
$role = 'viewer';
|
||||
}
|
||||
}
|
||||
|
||||
$id = db_insert('users', [
|
||||
'username' => $username,
|
||||
'display_name' => $display_name ?: $username,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $id;
|
||||
respond(201, [
|
||||
'id' => $id,
|
||||
'username' => $username,
|
||||
'display_name' => $display_name ?: $username,
|
||||
'role' => $role,
|
||||
]);
|
||||
}
|
||||
|
||||
function handle_login(): void {
|
||||
$data = json_body();
|
||||
$username = trim($data['username'] ?? '');
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
check_login_rate($username);
|
||||
|
||||
$user = db_one("SELECT * FROM users WHERE username = ?", [$username]);
|
||||
if (!$user || !password_verify($password, $user['password_hash'] ?? '')) {
|
||||
record_login_failure($username);
|
||||
respond(401, ['error' => 'Invalid username or password']);
|
||||
return;
|
||||
}
|
||||
|
||||
clear_login_rate($username);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
respond(200, [
|
||||
'id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'display_name' => $user['display_name'],
|
||||
'role' => $user['role'],
|
||||
]);
|
||||
}
|
||||
|
||||
function handle_logout(): void {
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params['path'], $params['domain'],
|
||||
$params['secure'], $params['httponly']
|
||||
);
|
||||
}
|
||||
respond(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
function handle_me(): void {
|
||||
$user = current_user();
|
||||
if (!$user) {
|
||||
respond(200, ['user' => null]);
|
||||
return;
|
||||
}
|
||||
respond(200, ['user' => $user]);
|
||||
}
|
||||
|
||||
function handle_sso_callback(): void {
|
||||
respond(501, ['error' => 'SSO not configured on this instance. Configure SSO in api/auth.php']);
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Config helpers — read/write site configuration from the config table.
|
||||
*/
|
||||
|
||||
function config_get(string $key, $default = null) {
|
||||
$row = db_one("SELECT value FROM config WHERE `key` = ?", [$key]);
|
||||
return $row ? $row['value'] : $default;
|
||||
}
|
||||
|
||||
function config_set(string $key, string $value): void {
|
||||
db_query(
|
||||
"INSERT INTO config (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)",
|
||||
[$key, $value]
|
||||
);
|
||||
}
|
||||
|
||||
function config_all(): array {
|
||||
$rows = db_all("SELECT `key`, `value` FROM config");
|
||||
$cfg = [];
|
||||
foreach ($rows as $row) {
|
||||
$cfg[$row['key']] = $row['value'];
|
||||
}
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
function site_config(): array {
|
||||
return [
|
||||
'name' => config_get('site_name', 'Protocol Droid'),
|
||||
'tagline' => config_get('site_tagline', 'A commons of social protocols'),
|
||||
'registration_enabled' => config_get('registration_enabled', 'true') === 'true',
|
||||
'require_approval' => config_get('require_approval', 'false') === 'true',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a config.yaml file if present (for whitelabel customization).
|
||||
* Merges with database config (database takes precedence).
|
||||
*/
|
||||
function load_yaml_config(): array {
|
||||
$path = __DIR__ . '/../config.yaml';
|
||||
if (!file_exists($path)) return [];
|
||||
$yaml_text = file_get_contents($path);
|
||||
return yaml_parse($yaml_text) ?: [];
|
||||
}
|
||||
|
||||
function merged_config(): array {
|
||||
$yaml_cfg = load_yaml_config();
|
||||
$db_cfg = site_config();
|
||||
return array_merge($yaml_cfg, $db_cfg);
|
||||
}
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* Database connection and helpers.
|
||||
* Uses MySQL/MariaDB via PDO (Cloudron-compatible).
|
||||
*/
|
||||
|
||||
function db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$host = getenv('MYSQL_HOST') ?: 'localhost';
|
||||
$port = getenv('MYSQL_PORT') ?: '3306';
|
||||
$name = getenv('MYSQL_DATABASE') ?: 'protocol_droid';
|
||||
$user = getenv('MYSQL_USER') ?: 'root';
|
||||
$pass = getenv('MYSQL_PASSWORD') ?: '';
|
||||
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function db_init(): void {
|
||||
$sql = file_get_contents(__DIR__ . '/../schema.sql');
|
||||
if (!$sql) return;
|
||||
// Split on semicolons at end of statements (simple split — schema has no complex strings with semicolons)
|
||||
$statements = array_filter(array_map('trim', preg_split('/;\s*(?=\n|$)/', $sql)));
|
||||
foreach ($statements as $stmt) {
|
||||
if (empty($stmt)) continue;
|
||||
try {
|
||||
db()->exec($stmt);
|
||||
} catch (PDOException $e) {
|
||||
// Ignore "already exists" errors
|
||||
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||
error_log("Schema init: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function db_query(string $sql, array $params = []): PDOStatement {
|
||||
$stmt = db()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
function db_one(string $sql, array $params = []): ?array {
|
||||
$stmt = db_query($sql, $params);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
function db_all(string $sql, array $params = []): array {
|
||||
$stmt = db_query($sql, $params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function db_insert(string $table, array $data): int {
|
||||
$cols = implode(', ', array_map(fn($c) => "`$c`", array_keys($data)));
|
||||
$placeholders = implode(', ', array_fill(0, count($data), '?'));
|
||||
$sql = "INSERT INTO {$table} ({$cols}) VALUES ({$placeholders})";
|
||||
db_query($sql, array_values($data));
|
||||
return (int) db()->lastInsertId();
|
||||
}
|
||||
|
||||
function db_update(string $table, array $data, string $where, array $whereParams): int {
|
||||
$set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data)));
|
||||
$sql = "UPDATE {$table} SET {$set} WHERE {$where}";
|
||||
db_query($sql, array_merge(array_values($data), $whereParams));
|
||||
return db_query("SELECT ROW_COUNT()")->fetchColumn();
|
||||
}
|
||||
|
||||
function slugify(string $text): string {
|
||||
$slug = strtolower($text);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
|
||||
$slug = trim($slug, '-');
|
||||
$slug = substr($slug, 0, 128);
|
||||
return $slug ?: 'untitled';
|
||||
}
|
||||
|
||||
function ensure_unique_slug(string $base): string {
|
||||
$slug = $base;
|
||||
$i = 1;
|
||||
while (db_one("SELECT slug FROM protocols WHERE slug = ?", [$slug]) ||
|
||||
db_one("SELECT slug FROM collections WHERE slug = ?", [$slug])) {
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
Executable
+515
@@ -0,0 +1,515 @@
|
||||
<?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') respond(405, ['error' => 'Method not allowed']);
|
||||
|
||||
$where = "WHERE p.visibility = 'public'";
|
||||
$params = [];
|
||||
|
||||
$tag = $_GET['tag'] ?? null;
|
||||
if ($tag) {
|
||||
// Use JSON_CONTAINS for accurate tag matching (MySQL 5.7+)
|
||||
$where .= " AND JSON_CONTAINS(p.tags, JSON_QUOTE(?))";
|
||||
$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));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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 DUPLICATE KEY UPDATE value = VALUES(value), comment = VALUES(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)]);
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* YAML import/export helpers.
|
||||
* Uses the yaml extension if available, falls back to a minimal parser.
|
||||
*/
|
||||
|
||||
function protocol_to_yaml(array $p): string {
|
||||
$lines = [];
|
||||
$lines[] = "id: " . ($p['slug'] ?? '');
|
||||
$lines[] = "title: " . yaml_quote($p['title'] ?? '');
|
||||
$lines[] = "description: " . yaml_multiline($p['description'] ?? '');
|
||||
$lines[] = "source: " . yaml_quote($p['source'] ?? '');
|
||||
if (!empty($p['source_url'])) $lines[] = "source_url: " . yaml_quote($p['source_url']);
|
||||
$lines[] = "tags: " . yaml_array(json_decode($p['tags'] ?? '[]', true) ?: []);
|
||||
if (!empty($p['forked_from'])) $lines[] = "forked_from: " . yaml_quote($p['forked_from']);
|
||||
if (!empty($p['image'])) $lines[] = "image: " . yaml_quote($p['image']);
|
||||
$lines[] = "steps:";
|
||||
$steps = json_decode($p['steps'] ?? '[]', true) ?: [];
|
||||
foreach ($steps as $step) {
|
||||
$lines[] = " - headline: " . yaml_quote($step['headline'] ?? '');
|
||||
$lines[] = " description: " . yaml_multiline($step['description'] ?? '', 6);
|
||||
}
|
||||
if (!empty($p['outcome'])) $lines[] = "outcome: " . yaml_multiline($p['outcome']);
|
||||
if (!empty($p['practice'])) $lines[] = "practice: " . yaml_multiline($p['practice']);
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
function collection_to_yaml(array $c, array $items): string {
|
||||
$lines = [];
|
||||
$lines[] = "id: " . ($c['slug'] ?? '');
|
||||
$lines[] = "title: " . yaml_quote($c['title'] ?? '');
|
||||
$lines[] = "description: " . yaml_multiline($c['description'] ?? '');
|
||||
$lines[] = "source: " . yaml_quote($c['source'] ?? '');
|
||||
if (!empty($c['image'])) $lines[] = "image: " . yaml_quote($c['image']);
|
||||
$lines[] = "protocols:";
|
||||
foreach ($items as $item) {
|
||||
if ($item['item_type'] === 'protocol') {
|
||||
$lines[] = " - " . $item['item_slug'];
|
||||
}
|
||||
}
|
||||
$nested = array_filter($items, fn($i) => $i['item_type'] === 'collection');
|
||||
if ($nested) {
|
||||
$lines[] = "collections:";
|
||||
foreach ($nested as $item) {
|
||||
$lines[] = " - " . $item['item_slug'];
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines) . "\n";
|
||||
}
|
||||
|
||||
function yaml_quote(string $s): string {
|
||||
if ($s === '') return '""';
|
||||
if (preg_match('/[:#{}&*!|>"\'\[\]]/', $s) || preg_match('/^\s/', $s)) {
|
||||
$s = str_replace('"', '\\"', $s);
|
||||
return '"' . $s . '"';
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
function yaml_multiline(string $s, int $indent = 0): string {
|
||||
if ($s === '') return '""';
|
||||
$pad = str_repeat(' ', $indent);
|
||||
// Use folded scalar for readability
|
||||
$lines = explode("\n", trim($s));
|
||||
if (count($lines) <= 1 && strlen($s) < 60) {
|
||||
return yaml_quote($s);
|
||||
}
|
||||
$out = ">-";
|
||||
foreach ($lines as $line) {
|
||||
$out .= "\n" . $pad . " " . $line;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function yaml_array(array $arr): string {
|
||||
if (empty($arr)) return '[]';
|
||||
return '[' . implode(', ', array_map('yaml_quote', $arr)) . ']';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a YAML protocol file into a database-ready array.
|
||||
* Uses yaml_parse if available, otherwise a simple parser for the protocol format.
|
||||
*/
|
||||
function parse_protocol_yaml(string $yaml_text): ?array {
|
||||
if (function_exists('yaml_parse')) {
|
||||
// Harden against PHP object injection via !php/object tags
|
||||
$data = yaml_parse($yaml_text, -1, $ndocs, [
|
||||
'!php/object' => false,
|
||||
'!php/const' => false,
|
||||
]);
|
||||
if (!is_array($data)) return null;
|
||||
} else {
|
||||
$data = simple_yaml_parse($yaml_text);
|
||||
if (!is_array($data)) return null;
|
||||
}
|
||||
|
||||
$steps = [];
|
||||
foreach (($data['steps'] ?? []) as $step) {
|
||||
$steps[] = [
|
||||
'headline' => $step['headline'] ?? '',
|
||||
'description' => $step['description'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => $data['id'] ?? slugify($data['title'] ?? 'untitled'),
|
||||
'title' => $data['title'] ?? '',
|
||||
'description' => $data['description'] ?? '',
|
||||
'source' => $data['source'] ?? '',
|
||||
'source_url' => $data['source_url'] ?? '',
|
||||
'tags' => json_encode($data['tags'] ?? []),
|
||||
'forked_from' => $data['forked_from'] ?? null,
|
||||
'image' => $data['image'] ?? null,
|
||||
'steps' => json_encode($steps),
|
||||
'outcome' => $data['outcome'] ?? '',
|
||||
'practice' => $data['practice'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML parser for simple protocol files.
|
||||
* Handles: top-level key: value, nested lists with - headline:, folded scalars
|
||||
* This is NOT a full YAML parser — it handles the protocol format only.
|
||||
*/
|
||||
function simple_yaml_parse(string $text): ?array {
|
||||
$result = [];
|
||||
$lines = explode("\n", $text);
|
||||
$current_key = null;
|
||||
$in_steps = false;
|
||||
$in_folded = false;
|
||||
$folded_buffer = '';
|
||||
$folded_key = null;
|
||||
$folded_indent = 0;
|
||||
|
||||
$flush_folded = function() use (&$result, &$folded_buffer, &$folded_key, &$in_folded) {
|
||||
if ($in_folded && $folded_key !== null) {
|
||||
$result[$folded_key] = trim($folded_buffer);
|
||||
$folded_buffer = '';
|
||||
$in_folded = false;
|
||||
$folded_key = null;
|
||||
}
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip comments and empty lines
|
||||
if (preg_match('/^\s*#/', $line) || trim($line) === '') continue;
|
||||
|
||||
// Folded scalar continuation
|
||||
if ($in_folded) {
|
||||
if (preg_match('/^(\s{' . ($folded_indent + 2) . ',})/', $line, $m)) {
|
||||
$folded_buffer .= ' ' . trim($line);
|
||||
continue;
|
||||
} else {
|
||||
$flush_folded();
|
||||
}
|
||||
}
|
||||
|
||||
// Steps list items
|
||||
if (preg_match('/^(\s+) - headline:\s*(.*)$/', $line, $m)) {
|
||||
$in_steps = true;
|
||||
$result['steps'][] = ['headline' => trim($m[2], '"'), 'description' => ''];
|
||||
$current_key = 'description';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^(\s+) description:\s*(.*)$/', $line, $m)) {
|
||||
$val = trim($m[2], '"');
|
||||
if (str_starts_with($val, '>-') || str_starts_with($val, '|-')) {
|
||||
$in_folded = true;
|
||||
$folded_key = null; // Will set on last step
|
||||
$folded_buffer = '';
|
||||
$folded_indent = strlen($m[1]);
|
||||
} else {
|
||||
if (!empty($result['steps'])) {
|
||||
$result['steps'][count($result['steps']) - 1]['description'] = $val;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level key: value
|
||||
if (preg_match('/^([a-z_]+):\s*(.*)$/', $line, $m)) {
|
||||
$flush_folded();
|
||||
$key = $m[1];
|
||||
$val = trim($m[2]);
|
||||
if ($val === '' ) {
|
||||
$current_key = $key;
|
||||
if ($key === 'steps') { $result['steps'] = []; $in_steps = true; }
|
||||
continue;
|
||||
}
|
||||
// Parse inline array [a, b, c]
|
||||
if (str_starts_with($val, '[') && str_ends_with($val, ']')) {
|
||||
$inner = substr($val, 1, -1);
|
||||
$result[$key] = array_map(fn($s) => trim($s, ' "'), array_filter(explode(',', $inner), fn($s) => trim($s) !== ''));
|
||||
continue;
|
||||
}
|
||||
$result[$key] = trim($val, '"');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$flush_folded();
|
||||
|
||||
return $result;
|
||||
}
|
||||
Reference in New Issue
Block a user