From 3386ac654226fdb33899071abaaadc38ca824b7c Mon Sep 17 00:00:00 2001 From: Protocolbot Date: Mon, 6 Jul 2026 14:42:45 -0600 Subject: [PATCH] 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 --- Dockerfile | 6 +-- README.md | 46 +++++++++++------- api/config.php | 6 +-- api/db.php | 46 +++++++++--------- api/index.php | 88 +++++++++++++++++----------------- api/yaml.php | 10 ++-- cloudron/CloudronManifest.json | 8 +--- schema.sql | 77 ++++++++++++++++------------- 8 files changed, 155 insertions(+), 132 deletions(-) mode change 100755 => 100644 api/config.php mode change 100755 => 100644 api/db.php mode change 100755 => 100644 api/index.php mode change 100755 => 100644 api/yaml.php mode change 100755 => 100644 schema.sql diff --git a/Dockerfile b/Dockerfile index 5dd5bdc..fae0aa2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM php:8.2-apache -# Install MySQL PDO extension and YAML extension -RUN docker-php-ext-install pdo pdo_mysql mysqli +# Install YAML extension (pdo_sqlite is already included in php:8.2-apache) +RUN docker-php-ext-install pdo # Install PECL yaml extension RUN apt-get update && apt-get install -y libyaml-dev && \ @@ -18,7 +18,7 @@ RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-av # Copy app files COPY . /var/www/html/ -# Ensure data directory is writable +# Ensure data directory is writable (for SQLite database) RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md index c530fa9..8dd2325 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Protocol Droid is a web application where communities can document, share, and r - **Identity**: user accounts with optional SSO support - **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable - **Whitelabel**: customize site name, tagline, and theme via `config.yaml` +- **Zero-dependency database**: SQLite — no external database server required ## Architecture @@ -24,7 +25,7 @@ Protocol Droid is a web application where communities can document, share, and r protocol-droid/ ├── api/ PHP backend (REST API) │ ├── index.php API router (all /api/* requests) -│ ├── db.php Database connection + helpers (MySQL/MariaDB) +│ ├── db.php Database connection + helpers (SQLite via PDO) │ ├── auth.php Session-based authentication │ ├── config.php Site configuration │ └── yaml.php YAML import/export helpers @@ -41,7 +42,8 @@ protocol-droid/ │ └── fishbowl-discussion.yaml ├── cloudron/ Cloudron deployment files │ └── CloudronManifest.json -├── schema.sql MySQL database schema +├── data/ SQLite database (auto-created) +├── schema.sql SQLite database schema ├── config.yaml Whitelabel configuration ├── .htaccess Apache rewrite rules └── README.md This file @@ -49,19 +51,19 @@ protocol-droid/ ## Stack -- **Backend**: PHP 8.x with PDO (MySQL/MariaDB) -- **Database**: MySQL/MariaDB (Cloudron-native) +- **Backend**: PHP 8.x with PDO (SQLite) +- **Database**: SQLite — file-based, no server required - **Frontend**: Vanilla HTML/CSS/JS — no frameworks, no build step, no external dependencies -- **Data format**: YAML for portability, MySQL for runtime +- **Data format**: YAML for portability, SQLite for runtime - **Auth**: Session-based with optional SSO ## Deployment ### On Cloudron -1. Create a LAMP app in Cloudron +1. Create a LAMP app in Cloudron (no MySQL addon needed) 2. Upload all files to the app's web root -3. The database schema auto-initializes on first API request +3. The database schema auto-initializes on first API request (creates `data/protocol_droid.db`) 4. Visit the site and register — the **first user to register becomes admin** 5. Optionally load seed protocols: sign in as admin and `POST /api/seed` (or use the API endpoint with curl: `curl -b cookies.txt -X POST https://your-site/api/seed`) @@ -70,25 +72,34 @@ protocol-droid/ ### On any LAMP server 1. Upload files to your web root (or a subdirectory like `/protocol-droid/`) -2. Create a MySQL database and user -3. Set environment variables (or edit `api/db.php`): - - `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` -4. Visit the site and register — first user becomes admin +2. Ensure the `data/` directory is writable by the web server +3. Visit the site and register — first user becomes admin + +The SQLite database file is created automatically at `data/protocol_droid.db`. To use a custom path, set the `DATABASE_PATH` environment variable. **Subdirectory deployment:** Protocol Droid works in a subdirectory (e.g. `example.com/protocol-droid/`). The root `index.php` auto-detects the base path from the server's script name. No configuration needed — just upload the files to the subdirectory and ensure `index.php` is served as the directory index (most LAMP setups do this by default with `DirectoryIndex index.php` or `.htaccess`). +### With Docker + +```bash +# Build and run a single container (no separate database container needed) +docker build -t protocol-droid . +docker run -d --name pd-web -p 8080:80 \ + -v pd_data:/var/www/html/data \ + protocol-droid +``` + +The SQLite database lives in the `pd_data` volume. The `docker-compose.yml` included in the repo runs the same setup. + ### Local development ```bash # Start PHP built-in server cd protocol-droid php -S localhost:8000 - -# Or with a MySQL database: -MYSQL_HOST=localhost MYSQL_DATABASE=protocol_droid MYSQL_USER=root MYSQL_PASSWORD=secret php -S localhost:8000 ``` -The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): +The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): ```bash # After registering as admin: @@ -112,14 +123,15 @@ curl -b cookies.txt -X DELETE http://localhost:8000/api/protocols/round-robin-ch | `/api` | GET | API info | none | | `/api/config` | GET | Site configuration | none | | `/api/protocols` | GET | List public protocols | none | -| `/api/protocols/:slug` | GET | Get protocol | none | | `/api/protocols` | POST | Create protocol | member | +| `/api/protocols/:slug` | GET | Get protocol | none | | `/api/protocols/:slug` | PUT | Update protocol | author/admin | | `/api/protocols/:slug` | DELETE | Delete protocol | author/admin | | `/api/protocols/:slug/yaml` | GET | Export as YAML | none | +| `/api/protocols/:slug/votes` | GET | Get votes for protocol | none | | `/api/collections` | GET | List collections | none | -| `/api/collections/:slug` | GET | Get collection | none | | `/api/collections` | POST | Create collection | member | +| `/api/collections/:slug` | GET | Get collection | none | | `/api/collections/:slug` | PUT | Update collection | author/admin | | `/api/collections/:slug` | DELETE | Delete collection | author/admin | | `/api/auth/register` | POST | Register | none | diff --git a/api/config.php b/api/config.php old mode 100755 new mode 100644 index 0aac893..1f990c3 --- a/api/config.php +++ b/api/config.php @@ -4,19 +4,19 @@ */ function config_get(string $key, $default = null) { - $row = db_one("SELECT value FROM config WHERE `key` = ?", [$key]); + $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`)", + "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [$key, $value] ); } function config_all(): array { - $rows = db_all("SELECT `key`, `value` FROM config"); + $rows = db_all("SELECT key, value FROM config"); $cfg = []; foreach ($rows as $row) { $cfg[$row['key']] = $row['value']; diff --git a/api/db.php b/api/db.php old mode 100755 new mode 100644 index a854fe3..3bfa0d8 --- a/api/db.php +++ b/api/db.php @@ -1,25 +1,35 @@ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); + // Enable foreign key enforcement (off by default in SQLite) + $pdo->exec('PRAGMA foreign_keys = ON'); } catch (PDOException $e) { http_response_code(500); echo json_encode(['error' => 'Database connection failed']); @@ -32,19 +42,7 @@ function db(): 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()); - } - } - } + db()->exec($sql); } function db_query(string $sql, array $params = []): PDOStatement { @@ -75,8 +73,8 @@ function db_insert(string $table, array $data): int { 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(); + $stmt = db_query($sql, array_merge(array_values($data), $whereParams)); + return $stmt->rowCount(); } function slugify(string $text): string { diff --git a/api/index.php b/api/index.php old mode 100755 new mode 100644 index 77def9a..7920248 --- a/api/index.php +++ b/api/index.php @@ -169,29 +169,56 @@ if ($parts[0] === 'protocols') { if (!$slug) { // List protocols - if ($method !== 'GET') respond(405, ['error' => 'Method not allowed']); + if ($method === 'GET') { + $where = "WHERE p.visibility = 'public'"; + $params = []; - $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; + } - $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)); } - $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%"]); + // 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)); } - $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)); + respond(405, ['error' => 'Method not allowed']); } // YAML export @@ -236,31 +263,6 @@ if ($parts[0] === 'protocols') { 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(); @@ -459,7 +461,7 @@ if ($parts[0] === 'votes' && $method === 'POST') { // Upsert vote db_query( "INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE value = VALUES(value), comment = VALUES(comment)", + 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]); diff --git a/api/yaml.php b/api/yaml.php old mode 100755 new mode 100644 index 5bae01a..235d3cf --- a/api/yaml.php +++ b/api/yaml.php @@ -83,10 +83,12 @@ function yaml_array(array $arr): string { */ 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, + // Harden against PHP object injection via !php/object and !php/const tags. + // The callbacks must be valid callables; null them out to neutralize deserialization. + $neutralize = fn() => null; + $data = yaml_parse($yaml_text, 0, $ndocs, [ + '!php/object' => $neutralize, + '!php/const' => $neutralize, ]); if (!is_array($data)) return null; } else { diff --git a/cloudron/CloudronManifest.json b/cloudron/CloudronManifest.json index c5b29c3..bc662bf 100644 --- a/cloudron/CloudronManifest.json +++ b/cloudron/CloudronManifest.json @@ -4,18 +4,14 @@ "author": "Media Economies Design Lab, CU Boulder", "description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.", "tagline": "A commons of social protocols", - "version": "0.1.0", + "version": "0.3.0", "healthCheckPath": "/api", "httpPort": 8000, "manifestVersion": 2, "website": "https://medlab.host", "contactEmail": "medlab@colorado.edu", "icon": "file://icon.png", - "addons": { - "mysql": { - "noDatabase": false - } - }, + "addons": {}, "tcpPorts": {}, "udpPorts": {}, "capabilities": [], diff --git a/schema.sql b/schema.sql old mode 100755 new mode 100644 index aa9f45a..8f8df99 --- a/schema.sql +++ b/schema.sql @@ -1,20 +1,20 @@ --- Protocol Droid — MySQL Schema --- Works with MySQL 5.7+ / MariaDB 10.3+ +-- Protocol Droid — SQLite Schema +-- Works with SQLite 3.35+ (for ON CONFLICT support) CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(32) UNIQUE NOT NULL, display_name VARCHAR(128), bio TEXT, password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc. sso_id VARCHAR(255), -- external identity id - role ENUM('admin','member','viewer') DEFAULT 'member', + role VARCHAR(10) DEFAULT 'member' CHECK (role IN ('admin','member','viewer')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +); CREATE TABLE IF NOT EXISTS protocols ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, slug VARCHAR(128) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, @@ -26,56 +26,69 @@ CREATE TABLE IF NOT EXISTS protocols ( steps TEXT, -- JSON array of {headline, description} outcome TEXT, practice TEXT, - author_id INT REFERENCES users(id), - visibility ENUM('public','private','unlisted') DEFAULT 'public', + author_id INTEGER REFERENCES users(id), + visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_visibility (visibility), - INDEX idx_author (author_id), - INDEX idx_forked (forked_from) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_protocols_visibility ON protocols (visibility); +CREATE INDEX IF NOT EXISTS idx_protocols_author ON protocols (author_id); +CREATE INDEX IF NOT EXISTS idx_protocols_forked ON protocols (forked_from); + +-- Trigger to update updated_at on row modification +CREATE TRIGGER IF NOT EXISTS protocols_updated_at +AFTER UPDATE ON protocols +BEGIN + UPDATE protocols SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; CREATE TABLE IF NOT EXISTS collections ( - id INT AUTO_INCREMENT PRIMARY KEY, + id INTEGER PRIMARY KEY AUTOINCREMENT, slug VARCHAR(128) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, source VARCHAR(255), image VARCHAR(500), - author_id INT REFERENCES users(id), - visibility ENUM('public','private','unlisted') DEFAULT 'public', + author_id INTEGER REFERENCES users(id), + visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_visibility (visibility), - INDEX idx_author (author_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_collections_visibility ON collections (visibility); +CREATE INDEX IF NOT EXISTS idx_collections_author ON collections (author_id); + +CREATE TRIGGER IF NOT EXISTS collections_updated_at +AFTER UPDATE ON collections +BEGIN + UPDATE collections SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; CREATE TABLE IF NOT EXISTS collection_items ( - collection_id INT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - item_type ENUM('protocol','collection') NOT NULL, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('protocol','collection')), item_slug VARCHAR(128) NOT NULL, - position INT DEFAULT 0, + position INTEGER DEFAULT 0, PRIMARY KEY (collection_id, item_type, item_slug) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +); CREATE TABLE IF NOT EXISTS votes ( - user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, protocol_slug VARCHAR(128) NOT NULL, - value INT DEFAULT 1, -- +1 upvote, -1 downvote + value INTEGER DEFAULT 1, -- +1 upvote, -1 downvote comment TEXT, -- optional evaluation text created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, protocol_slug) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +); CREATE TABLE IF NOT EXISTS config ( - `key` VARCHAR(64) PRIMARY KEY, - `value` TEXT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + key VARCHAR(64) PRIMARY KEY, + value TEXT +); -- Default config entries -INSERT INTO config (`key`, `value`) VALUES +INSERT INTO config (key, value) VALUES ('site_name', 'Protocol Droid'), ('site_tagline', 'A commons of social protocols'), ('registration_enabled', 'true'), ('require_approval', 'false') -ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); \ No newline at end of file +ON CONFLICT(key) DO UPDATE SET value = excluded.value; \ No newline at end of file