release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)

Plugin visibility control, UI/UX fixes, unified admin panel,
database-backed settings, and security hardening.

Highlights:
- ENABLED_PLUGINS env var for plugin visibility (F.1)
- Admin designation via ADMIN_EMAILS (F.4a)
- Master key scope control (F.4b)
- Panel unification + settings from UI (F.4c)
- exec() removal, shell injection fix, bcrypt migration (F.8)
- 5 new test suites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 09:55:49 +02:00
parent f6dbbeaab0
commit 9b70b259bb
47 changed files with 2122 additions and 366 deletions

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
SCHEMA_VERSION = 3
SCHEMA_VERSION = 5
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS sites (
status TEXT NOT NULL DEFAULT 'pending',
status_msg TEXT,
last_health TEXT,
last_tested_at TEXT,
created_at TEXT NOT NULL,
UNIQUE(user_id, alias)
);
@@ -108,6 +109,17 @@ _MIGRATIONS: dict[int, str] = {
"ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n"
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n"
),
4: "ALTER TABLE sites ADD COLUMN last_tested_at TEXT;\n",
5: (
"ALTER TABLE sites ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0;\n"
"CREATE UNIQUE INDEX IF NOT EXISTS idx_system_site_alias "
"ON sites(alias) WHERE is_system = 1;\n"
"CREATE TABLE IF NOT EXISTS settings (\n"
" key TEXT PRIMARY KEY,\n"
" value TEXT NOT NULL,\n"
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
}
@@ -519,15 +531,17 @@ class Database:
only updates if the site belongs to this user. When None,
performs system-level update (e.g., health checks).
"""
now = _utc_now()
if user_id is not None:
await self.execute(
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ? AND user_id = ?",
(status, status_msg, site_id, user_id),
"UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?"
" WHERE id = ? AND user_id = ?",
(status, status_msg, now, site_id, user_id),
)
else:
await self.execute(
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ?",
(status, status_msg, site_id),
"UPDATE sites SET status = ?, status_msg = ?, last_tested_at = ?" " WHERE id = ?",
(status, status_msg, now, site_id),
)
async def update_site_credentials(
@@ -584,6 +598,33 @@ class Database:
)
return row["cnt"] if row else 0
# ------------------------------------------------------------------
# Settings CRUD (Phase 4C.3)
# ------------------------------------------------------------------
async def get_setting(self, key: str) -> str | None:
"""Get a setting value by key."""
row = await self.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
return row["value"] if row else None
async def set_setting(self, key: str, value: str) -> None:
"""Set a setting value (upsert)."""
await self.execute(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?",
(key, value, _utc_now(), value, _utc_now()),
)
async def delete_setting(self, key: str) -> bool:
"""Delete a setting. Returns True if deleted."""
cursor = await self.execute("DELETE FROM settings WHERE key = ?", (key,))
return cursor.rowcount > 0
async def get_all_settings(self) -> dict[str, str]:
"""Get all settings as a dict."""
rows = await self.fetchall("SELECT key, value FROM settings")
return {r["key"]: r["value"] for r in rows}
# ------------------------------------------------------------------
# User API Key CRUD
# ------------------------------------------------------------------