02794e565e
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).
203 lines
7.1 KiB
PHP
Executable File
203 lines
7.1 KiB
PHP
Executable File
<?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;
|
|
} |