Files
Protocolbot 02794e565e 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).
2026-07-06 13:18:08 -06:00

223 lines
6.9 KiB
PHP
Executable File

<?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']);
}