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
This commit is contained in:
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
FROM php:8.2-apache
|
FROM php:8.2-apache
|
||||||
|
|
||||||
# Install MySQL PDO extension and YAML extension
|
# Install YAML extension (pdo_sqlite is already included in php:8.2-apache)
|
||||||
RUN docker-php-ext-install pdo pdo_mysql mysqli
|
RUN docker-php-ext-install pdo
|
||||||
|
|
||||||
# Install PECL yaml extension
|
# Install PECL yaml extension
|
||||||
RUN apt-get update && apt-get install -y libyaml-dev && \
|
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 app files
|
||||||
COPY . /var/www/html/
|
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
|
RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -17,6 +17,7 @@ Protocol Droid is a web application where communities can document, share, and r
|
|||||||
- **Identity**: user accounts with optional SSO support
|
- **Identity**: user accounts with optional SSO support
|
||||||
- **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable
|
- **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable
|
||||||
- **Whitelabel**: customize site name, tagline, and theme via `config.yaml`
|
- **Whitelabel**: customize site name, tagline, and theme via `config.yaml`
|
||||||
|
- **Zero-dependency database**: SQLite — no external database server required
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ Protocol Droid is a web application where communities can document, share, and r
|
|||||||
protocol-droid/
|
protocol-droid/
|
||||||
├── api/ PHP backend (REST API)
|
├── api/ PHP backend (REST API)
|
||||||
│ ├── index.php API router (all /api/* requests)
|
│ ├── 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
|
│ ├── auth.php Session-based authentication
|
||||||
│ ├── config.php Site configuration
|
│ ├── config.php Site configuration
|
||||||
│ └── yaml.php YAML import/export helpers
|
│ └── yaml.php YAML import/export helpers
|
||||||
@@ -41,7 +42,8 @@ protocol-droid/
|
|||||||
│ └── fishbowl-discussion.yaml
|
│ └── fishbowl-discussion.yaml
|
||||||
├── cloudron/ Cloudron deployment files
|
├── cloudron/ Cloudron deployment files
|
||||||
│ └── CloudronManifest.json
|
│ └── CloudronManifest.json
|
||||||
├── schema.sql MySQL database schema
|
├── data/ SQLite database (auto-created)
|
||||||
|
├── schema.sql SQLite database schema
|
||||||
├── config.yaml Whitelabel configuration
|
├── config.yaml Whitelabel configuration
|
||||||
├── .htaccess Apache rewrite rules
|
├── .htaccess Apache rewrite rules
|
||||||
└── README.md This file
|
└── README.md This file
|
||||||
@@ -49,19 +51,19 @@ protocol-droid/
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend**: PHP 8.x with PDO (MySQL/MariaDB)
|
- **Backend**: PHP 8.x with PDO (SQLite)
|
||||||
- **Database**: MySQL/MariaDB (Cloudron-native)
|
- **Database**: SQLite — file-based, no server required
|
||||||
- **Frontend**: Vanilla HTML/CSS/JS — no frameworks, no build step, no external dependencies
|
- **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
|
- **Auth**: Session-based with optional SSO
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### On Cloudron
|
### 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
|
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**
|
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`)
|
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
|
### On any LAMP server
|
||||||
|
|
||||||
1. Upload files to your web root (or a subdirectory like `/protocol-droid/`)
|
1. Upload files to your web root (or a subdirectory like `/protocol-droid/`)
|
||||||
2. Create a MySQL database and user
|
2. Ensure the `data/` directory is writable by the web server
|
||||||
3. Set environment variables (or edit `api/db.php`):
|
3. Visit the site and register — first user becomes admin
|
||||||
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD`
|
|
||||||
4. 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`).
|
**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
|
### Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start PHP built-in server
|
# Start PHP built-in server
|
||||||
cd protocol-droid
|
cd protocol-droid
|
||||||
php -S localhost:8000
|
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
|
```bash
|
||||||
# After registering as admin:
|
# 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` | GET | API info | none |
|
||||||
| `/api/config` | GET | Site configuration | none |
|
| `/api/config` | GET | Site configuration | none |
|
||||||
| `/api/protocols` | GET | List public protocols | none |
|
| `/api/protocols` | GET | List public protocols | none |
|
||||||
| `/api/protocols/:slug` | GET | Get protocol | none |
|
|
||||||
| `/api/protocols` | POST | Create protocol | member |
|
| `/api/protocols` | POST | Create protocol | member |
|
||||||
|
| `/api/protocols/:slug` | GET | Get protocol | none |
|
||||||
| `/api/protocols/:slug` | PUT | Update protocol | author/admin |
|
| `/api/protocols/:slug` | PUT | Update protocol | author/admin |
|
||||||
| `/api/protocols/:slug` | DELETE | Delete protocol | author/admin |
|
| `/api/protocols/:slug` | DELETE | Delete protocol | author/admin |
|
||||||
| `/api/protocols/:slug/yaml` | GET | Export as YAML | none |
|
| `/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` | GET | List collections | none |
|
||||||
| `/api/collections/:slug` | GET | Get collection | none |
|
|
||||||
| `/api/collections` | POST | Create collection | member |
|
| `/api/collections` | POST | Create collection | member |
|
||||||
|
| `/api/collections/:slug` | GET | Get collection | none |
|
||||||
| `/api/collections/:slug` | PUT | Update collection | author/admin |
|
| `/api/collections/:slug` | PUT | Update collection | author/admin |
|
||||||
| `/api/collections/:slug` | DELETE | Delete collection | author/admin |
|
| `/api/collections/:slug` | DELETE | Delete collection | author/admin |
|
||||||
| `/api/auth/register` | POST | Register | none |
|
| `/api/auth/register` | POST | Register | none |
|
||||||
|
|||||||
Executable → Regular
+3
-3
@@ -4,19 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function config_get(string $key, $default = null) {
|
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;
|
return $row ? $row['value'] : $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
function config_set(string $key, string $value): void {
|
function config_set(string $key, string $value): void {
|
||||||
db_query(
|
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]
|
[$key, $value]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function config_all(): array {
|
function config_all(): array {
|
||||||
$rows = db_all("SELECT `key`, `value` FROM config");
|
$rows = db_all("SELECT key, value FROM config");
|
||||||
$cfg = [];
|
$cfg = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$cfg[$row['key']] = $row['value'];
|
$cfg[$row['key']] = $row['value'];
|
||||||
|
|||||||
Executable → Regular
+22
-24
@@ -1,25 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Database connection and helpers.
|
* Database connection and helpers.
|
||||||
* Uses MySQL/MariaDB via PDO (Cloudron-compatible).
|
* Uses SQLite via PDO — no external database server required.
|
||||||
|
*
|
||||||
|
* The database file path is configurable via the DATABASE_PATH env var.
|
||||||
|
* Defaults to data/protocol_droid.db relative to the project root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function db_path(): string {
|
||||||
|
$configured = getenv('DATABASE_PATH');
|
||||||
|
if ($configured) return $configured;
|
||||||
|
return __DIR__ . '/../data/protocol_droid.db';
|
||||||
|
}
|
||||||
|
|
||||||
function db(): PDO {
|
function db(): PDO {
|
||||||
static $pdo = null;
|
static $pdo = null;
|
||||||
if ($pdo === null) {
|
if ($pdo === null) {
|
||||||
$host = getenv('MYSQL_HOST') ?: 'localhost';
|
$path = db_path();
|
||||||
$port = getenv('MYSQL_PORT') ?: '3306';
|
$dir = dirname($path);
|
||||||
$name = getenv('MYSQL_DATABASE') ?: 'protocol_droid';
|
if (!is_dir($dir)) {
|
||||||
$user = getenv('MYSQL_USER') ?: 'root';
|
mkdir($dir, 0775, true);
|
||||||
$pass = getenv('MYSQL_PASSWORD') ?: '';
|
}
|
||||||
|
$dsn = "sqlite:{$path}";
|
||||||
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
|
|
||||||
try {
|
try {
|
||||||
$pdo = new PDO($dsn, $user, $pass, [
|
$pdo = new PDO($dsn, null, null, [
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
]);
|
]);
|
||||||
|
// Enable foreign key enforcement (off by default in SQLite)
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['error' => 'Database connection failed']);
|
echo json_encode(['error' => 'Database connection failed']);
|
||||||
@@ -32,19 +42,7 @@ function db(): PDO {
|
|||||||
function db_init(): void {
|
function db_init(): void {
|
||||||
$sql = file_get_contents(__DIR__ . '/../schema.sql');
|
$sql = file_get_contents(__DIR__ . '/../schema.sql');
|
||||||
if (!$sql) return;
|
if (!$sql) return;
|
||||||
// Split on semicolons at end of statements (simple split — schema has no complex strings with semicolons)
|
db()->exec($sql);
|
||||||
$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 {
|
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 {
|
function db_update(string $table, array $data, string $where, array $whereParams): int {
|
||||||
$set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data)));
|
$set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data)));
|
||||||
$sql = "UPDATE {$table} SET {$set} WHERE {$where}";
|
$sql = "UPDATE {$table} SET {$set} WHERE {$where}";
|
||||||
db_query($sql, array_merge(array_values($data), $whereParams));
|
$stmt = db_query($sql, array_merge(array_values($data), $whereParams));
|
||||||
return db_query("SELECT ROW_COUNT()")->fetchColumn();
|
return $stmt->rowCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function slugify(string $text): string {
|
function slugify(string $text): string {
|
||||||
|
|||||||
Executable → Regular
+45
-43
@@ -169,29 +169,56 @@ if ($parts[0] === 'protocols') {
|
|||||||
|
|
||||||
if (!$slug) {
|
if (!$slug) {
|
||||||
// List protocols
|
// 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'";
|
$tag = $_GET['tag'] ?? null;
|
||||||
$params = [];
|
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;
|
$q = $_GET['q'] ?? null;
|
||||||
if ($tag) {
|
if ($q) {
|
||||||
// Use JSON_CONTAINS for accurate tag matching (MySQL 5.7+)
|
$where .= " AND (p.title LIKE ? OR p.description LIKE ? OR p.source LIKE ?)";
|
||||||
$where .= " AND JSON_CONTAINS(p.tags, JSON_QUOTE(?))";
|
$params = array_merge($params, ["%$q%", "%$q%", "%$q%"]);
|
||||||
$params[] = $tag;
|
}
|
||||||
|
|
||||||
|
$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;
|
// Create protocol
|
||||||
if ($q) {
|
if ($method === 'POST') {
|
||||||
$where .= " AND (p.title LIKE ? OR p.description LIKE ? OR p.source LIKE ?)";
|
$user = require_member();
|
||||||
$params = array_merge($params, ["%$q%", "%$q%", "%$q%"]);
|
$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
|
respond(405, ['error' => 'Method not allowed']);
|
||||||
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
|
// YAML export
|
||||||
@@ -236,31 +263,6 @@ if ($parts[0] === 'protocols') {
|
|||||||
respond(200, format_protocol($row));
|
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
|
// Update protocol — auth before existence check
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
$user = require_auth();
|
$user = require_auth();
|
||||||
@@ -459,7 +461,7 @@ if ($parts[0] === 'votes' && $method === 'POST') {
|
|||||||
// Upsert vote
|
// Upsert vote
|
||||||
db_query(
|
db_query(
|
||||||
"INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?)
|
"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]
|
[$user['id'], $protocol_slug, $value, $comment]
|
||||||
);
|
);
|
||||||
respond(200, ['ok' => true]);
|
respond(200, ['ok' => true]);
|
||||||
|
|||||||
Executable → Regular
+6
-4
@@ -83,10 +83,12 @@ function yaml_array(array $arr): string {
|
|||||||
*/
|
*/
|
||||||
function parse_protocol_yaml(string $yaml_text): ?array {
|
function parse_protocol_yaml(string $yaml_text): ?array {
|
||||||
if (function_exists('yaml_parse')) {
|
if (function_exists('yaml_parse')) {
|
||||||
// Harden against PHP object injection via !php/object tags
|
// Harden against PHP object injection via !php/object and !php/const tags.
|
||||||
$data = yaml_parse($yaml_text, -1, $ndocs, [
|
// The callbacks must be valid callables; null them out to neutralize deserialization.
|
||||||
'!php/object' => false,
|
$neutralize = fn() => null;
|
||||||
'!php/const' => false,
|
$data = yaml_parse($yaml_text, 0, $ndocs, [
|
||||||
|
'!php/object' => $neutralize,
|
||||||
|
'!php/const' => $neutralize,
|
||||||
]);
|
]);
|
||||||
if (!is_array($data)) return null;
|
if (!is_array($data)) return null;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,18 +4,14 @@
|
|||||||
"author": "Media Economies Design Lab, CU Boulder",
|
"author": "Media Economies Design Lab, CU Boulder",
|
||||||
"description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.",
|
"description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.",
|
||||||
"tagline": "A commons of social protocols",
|
"tagline": "A commons of social protocols",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"healthCheckPath": "/api",
|
"healthCheckPath": "/api",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
"website": "https://medlab.host",
|
"website": "https://medlab.host",
|
||||||
"contactEmail": "medlab@colorado.edu",
|
"contactEmail": "medlab@colorado.edu",
|
||||||
"icon": "file://icon.png",
|
"icon": "file://icon.png",
|
||||||
"addons": {
|
"addons": {},
|
||||||
"mysql": {
|
|
||||||
"noDatabase": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tcpPorts": {},
|
"tcpPorts": {},
|
||||||
"udpPorts": {},
|
"udpPorts": {},
|
||||||
"capabilities": [],
|
"capabilities": [],
|
||||||
|
|||||||
Executable → Regular
+45
-32
@@ -1,20 +1,20 @@
|
|||||||
-- Protocol Droid — MySQL Schema
|
-- Protocol Droid — SQLite Schema
|
||||||
-- Works with MySQL 5.7+ / MariaDB 10.3+
|
-- Works with SQLite 3.35+ (for ON CONFLICT support)
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username VARCHAR(32) UNIQUE NOT NULL,
|
username VARCHAR(32) UNIQUE NOT NULL,
|
||||||
display_name VARCHAR(128),
|
display_name VARCHAR(128),
|
||||||
bio TEXT,
|
bio TEXT,
|
||||||
password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only
|
password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only
|
||||||
sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc.
|
sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc.
|
||||||
sso_id VARCHAR(255), -- external identity id
|
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
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS protocols (
|
CREATE TABLE IF NOT EXISTS protocols (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug VARCHAR(128) UNIQUE NOT NULL,
|
slug VARCHAR(128) UNIQUE NOT NULL,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@@ -26,56 +26,69 @@ CREATE TABLE IF NOT EXISTS protocols (
|
|||||||
steps TEXT, -- JSON array of {headline, description}
|
steps TEXT, -- JSON array of {headline, description}
|
||||||
outcome TEXT,
|
outcome TEXT,
|
||||||
practice TEXT,
|
practice TEXT,
|
||||||
author_id INT REFERENCES users(id),
|
author_id INTEGER REFERENCES users(id),
|
||||||
visibility ENUM('public','private','unlisted') DEFAULT 'public',
|
visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
INDEX idx_visibility (visibility),
|
);
|
||||||
INDEX idx_author (author_id),
|
CREATE INDEX IF NOT EXISTS idx_protocols_visibility ON protocols (visibility);
|
||||||
INDEX idx_forked (forked_from)
|
CREATE INDEX IF NOT EXISTS idx_protocols_author ON protocols (author_id);
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
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 (
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug VARCHAR(128) UNIQUE NOT NULL,
|
slug VARCHAR(128) UNIQUE NOT NULL,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
source VARCHAR(255),
|
source VARCHAR(255),
|
||||||
image VARCHAR(500),
|
image VARCHAR(500),
|
||||||
author_id INT REFERENCES users(id),
|
author_id INTEGER REFERENCES users(id),
|
||||||
visibility ENUM('public','private','unlisted') DEFAULT 'public',
|
visibility VARCHAR(10) DEFAULT 'public' CHECK (visibility IN ('public','private','unlisted')),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
INDEX idx_visibility (visibility),
|
);
|
||||||
INDEX idx_author (author_id)
|
CREATE INDEX IF NOT EXISTS idx_collections_visibility ON collections (visibility);
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
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 (
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
collection_id INT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
item_type ENUM('protocol','collection') NOT NULL,
|
item_type VARCHAR(10) NOT NULL CHECK (item_type IN ('protocol','collection')),
|
||||||
item_slug VARCHAR(128) NOT NULL,
|
item_slug VARCHAR(128) NOT NULL,
|
||||||
position INT DEFAULT 0,
|
position INTEGER DEFAULT 0,
|
||||||
PRIMARY KEY (collection_id, item_type, item_slug)
|
PRIMARY KEY (collection_id, item_type, item_slug)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS votes (
|
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,
|
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
|
comment TEXT, -- optional evaluation text
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (user_id, protocol_slug)
|
PRIMARY KEY (user_id, protocol_slug)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
`key` VARCHAR(64) PRIMARY KEY,
|
key VARCHAR(64) PRIMARY KEY,
|
||||||
`value` TEXT
|
value TEXT
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
);
|
||||||
|
|
||||||
-- Default config entries
|
-- Default config entries
|
||||||
INSERT INTO config (`key`, `value`) VALUES
|
INSERT INTO config (key, value) VALUES
|
||||||
('site_name', 'Protocol Droid'),
|
('site_name', 'Protocol Droid'),
|
||||||
('site_tagline', 'A commons of social protocols'),
|
('site_tagline', 'A commons of social protocols'),
|
||||||
('registration_enabled', 'true'),
|
('registration_enabled', 'true'),
|
||||||
('require_approval', 'false')
|
('require_approval', 'false')
|
||||||
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
|
||||||
Reference in New Issue
Block a user