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:
Executable → Regular
+3
-3
@@ -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'];
|
||||
|
||||
Executable → Regular
+22
-24
@@ -1,25 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* 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 {
|
||||
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";
|
||||
$path = db_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
$dsn = "sqlite:{$path}";
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
$pdo = new PDO($dsn, null, null, [
|
||||
PDO::ATTR_ERRMODE => 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 {
|
||||
|
||||
Executable → Regular
+45
-43
@@ -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]);
|
||||
|
||||
Executable → Regular
+6
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user