|
|
@@ -3,6 +3,7 @@ import sqlite3
|
|
|
import shutil
|
|
|
import tempfile
|
|
|
import logging
|
|
|
+import hashlib
|
|
|
from urllib.parse import quote
|
|
|
from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
|
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
|
|
|
@@ -47,7 +48,7 @@ def init_db():
|
|
|
)
|
|
|
""")
|
|
|
|
|
|
- # Auto-migrate: Add description and is_readonly columns if they don't exist
|
|
|
+ # Auto-migrate: Add description, is_readonly, and password columns if they don't exist
|
|
|
cursor = conn.cursor()
|
|
|
cursor.execute("PRAGMA table_info(profiles)")
|
|
|
columns = [info[1] for info in cursor.fetchall()]
|
|
|
@@ -55,6 +56,8 @@ def init_db():
|
|
|
cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
|
|
|
if "is_readonly" not in columns:
|
|
|
cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
|
|
|
+ if "password" not in columns:
|
|
|
+ cursor.execute("ALTER TABLE profiles ADD COLUMN password TEXT DEFAULT NULL")
|
|
|
|
|
|
# Create default profiles if the DB is empty
|
|
|
cursor.execute("SELECT count(*) FROM profiles")
|
|
|
@@ -74,18 +77,75 @@ def get_db_connection():
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
return conn
|
|
|
|
|
|
+def hash_password(password: str) -> str:
|
|
|
+ return hashlib.sha256(password.encode('utf-8')).hexdigest()
|
|
|
+
|
|
|
+def is_compiling() -> bool:
|
|
|
+ """
|
|
|
+ Dynamically checks if a build is happening by scanning active processes.
|
|
|
+ Includes a timeout check to prevent infinite lockups if the builder hangs.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # Match the timeout set in docker-compose (default 900 seconds / 15 mins)
|
|
|
+ timeout_sec = int(os.environ.get("CBS_BUILD_TIMEOUT_SEC", 900))
|
|
|
+
|
|
|
+ # Get the overall system uptime to calculate process age
|
|
|
+ with open('/proc/uptime', 'r') as f:
|
|
|
+ sys_uptime = float(f.read().split()[0])
|
|
|
+
|
|
|
+ ticks_per_sec = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
|
|
|
+
|
|
|
+ for pid in os.listdir('/proc'):
|
|
|
+ if pid.isdigit():
|
|
|
+ try:
|
|
|
+ with open(f'/proc/{pid}/cmdline', 'r') as f:
|
|
|
+ cmdline = f.read().replace('\x00', ' ')
|
|
|
+
|
|
|
+ # 'waf' is the build system for ArduPilot
|
|
|
+ if 'waf' in cmdline:
|
|
|
+ # Read the process stats to find out when it started
|
|
|
+ with open(f'/proc/{pid}/stat', 'r') as f:
|
|
|
+ stat_fields = f.read().split()
|
|
|
+ # Field 22 (index 21) is starttime in clock ticks
|
|
|
+ starttime_ticks = int(stat_fields[21])
|
|
|
+ starttime_sec = starttime_ticks / ticks_per_sec
|
|
|
+
|
|
|
+ process_uptime = sys_uptime - starttime_sec
|
|
|
+
|
|
|
+ # If the waf process has been running longer than the timeout, it's hung
|
|
|
+ if process_uptime > timeout_sec:
|
|
|
+ logger.warning(f"Ignoring stale 'waf' process (PID {pid}): running for {process_uptime:.1f}s (exceeds {timeout_sec}s timeout)")
|
|
|
+ continue
|
|
|
+
|
|
|
+ return True
|
|
|
+ except (IOError, FileNotFoundError, IndexError, ValueError):
|
|
|
+ continue
|
|
|
+ except Exception as e:
|
|
|
+ logger.debug(f"Process scan failed/unavailable: {e}")
|
|
|
+
|
|
|
+ return False
|
|
|
+
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
|
async def index(request: Request, success_msg: str = None, error_msg: str = None):
|
|
|
conn = get_db_connection()
|
|
|
|
|
|
- profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
|
|
|
- active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
|
|
|
+ profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
|
|
|
+ profiles = []
|
|
|
+ for p in profiles_raw:
|
|
|
+ p_dict = dict(p)
|
|
|
+ p_dict['is_locked'] = bool(p_dict.get('password'))
|
|
|
+ profiles.append(p_dict)
|
|
|
+
|
|
|
+ active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
|
|
|
+ active_profile = dict(active_profile_raw) if active_profile_raw else None
|
|
|
+ if active_profile:
|
|
|
+ active_profile['is_locked'] = bool(active_profile.get('password'))
|
|
|
|
|
|
files = []
|
|
|
dirs = set()
|
|
|
|
|
|
if active_profile:
|
|
|
- files = conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()
|
|
|
+ files = [dict(f) for f in conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()]
|
|
|
for f in files:
|
|
|
dirname = os.path.dirname(f['filepath'])
|
|
|
if dirname:
|
|
|
@@ -100,7 +160,8 @@ async def index(request: Request, success_msg: str = None, error_msg: str = None
|
|
|
"files": files,
|
|
|
"dirs": sorted(list(dirs)),
|
|
|
"success_msg": success_msg,
|
|
|
- "error_msg": error_msg
|
|
|
+ "error_msg": error_msg,
|
|
|
+ "is_compiling": is_compiling()
|
|
|
})
|
|
|
|
|
|
@app.post("/profile/create")
|
|
|
@@ -122,9 +183,12 @@ async def create_profile(name: str = Form(...)):
|
|
|
async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
- prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
- if prof and prof['is_readonly']:
|
|
|
- return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
|
|
|
+ prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
+ if prof:
|
|
|
+ if prof['is_readonly']:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
|
|
|
+ if prof['password']:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a locked profile. Unlock it first.", status_code=303)
|
|
|
|
|
|
conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
|
|
|
conn.commit()
|
|
|
@@ -141,9 +205,12 @@ async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...))
|
|
|
async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
- prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
- if prof and prof['is_readonly']:
|
|
|
- return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
|
|
|
+ prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
+ if prof:
|
|
|
+ if prof['is_readonly']:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
|
|
|
+ if prof['password']:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a locked profile. Unlock it first.", status_code=303)
|
|
|
|
|
|
conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
|
|
|
conn.commit()
|
|
|
@@ -177,8 +244,8 @@ async def duplicate_profile(profile_id: int = Form(...)):
|
|
|
|
|
|
cursor.execute("UPDATE profiles SET is_active = 0")
|
|
|
|
|
|
- # Duplicates are NEVER read-only, even if cloned from a read-only profile
|
|
|
- cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, ?, 1, 0)", (new_name, desc))
|
|
|
+ # Duplicates are NEVER read-only, and NEVER locked by default
|
|
|
+ cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly, password) VALUES (?, ?, 1, 0, NULL)", (new_name, desc))
|
|
|
new_profile_id = cursor.lastrowid
|
|
|
|
|
|
files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
|
|
|
@@ -209,15 +276,18 @@ async def activate_profile(profile_id: int = Form(...)):
|
|
|
async def delete_profile(profile_id: int = Form(...)):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
- prof = conn.execute("SELECT is_readonly, is_active FROM profiles WHERE id = ?").fetchone()
|
|
|
+ prof = conn.execute("SELECT is_readonly, is_active, password FROM profiles WHERE id = ?").fetchone()
|
|
|
|
|
|
- if prof and prof['is_readonly']:
|
|
|
- msg = quote("Cannot delete a read-only profile.")
|
|
|
- return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
-
|
|
|
- if prof and prof['is_active']:
|
|
|
- msg = quote("Cannot delete the currently active profile. Switch to another first.")
|
|
|
- return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+ if prof:
|
|
|
+ if prof['is_readonly']:
|
|
|
+ msg = quote("Cannot delete a read-only profile.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+ if prof['password']:
|
|
|
+ msg = quote("Cannot delete a locked profile. Unlock it first.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+ if prof['is_active']:
|
|
|
+ msg = quote("Cannot delete the currently active profile. Switch to another first.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
|
|
|
conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
|
|
|
conn.commit()
|
|
|
@@ -227,8 +297,50 @@ async def delete_profile(profile_id: int = Form(...)):
|
|
|
logger.error(f"Profile deletion failed: {e}")
|
|
|
return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
|
|
|
|
|
|
+@app.post("/profile/lock")
|
|
|
+async def lock_profile(profile_id: int = Form(...), password: str = Form(...)):
|
|
|
+ try:
|
|
|
+ if not password.strip():
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Password cannot be empty.", status_code=303)
|
|
|
+
|
|
|
+ hashed_pw = hash_password(password.strip())
|
|
|
+ with get_db_connection() as conn:
|
|
|
+ prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
+ if prof and prof['is_readonly']:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot lock a system read-only profile.", status_code=303)
|
|
|
+
|
|
|
+ conn.execute("UPDATE profiles SET password = ? WHERE id = ?", (hashed_pw, profile_id))
|
|
|
+ conn.commit()
|
|
|
+ msg = quote("Profile locked successfully.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Profile lock failed: {e}")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to lock profile.", status_code=303)
|
|
|
+
|
|
|
+@app.post("/profile/unlock")
|
|
|
+async def unlock_profile(profile_id: int = Form(...), password: str = Form(...)):
|
|
|
+ try:
|
|
|
+ hashed_pw = hash_password(password.strip())
|
|
|
+ with get_db_connection() as conn:
|
|
|
+ prof = conn.execute("SELECT password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
+ if not prof or prof['password'] != hashed_pw:
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Incorrect password.", status_code=303)
|
|
|
+
|
|
|
+ conn.execute("UPDATE profiles SET password = NULL WHERE id = ?", (profile_id,))
|
|
|
+ conn.commit()
|
|
|
+ msg = quote("Profile unlocked successfully.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Profile unlock failed: {e}")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to unlock profile.", status_code=303)
|
|
|
+
|
|
|
@app.post("/deploy")
|
|
|
async def deploy_active_profile():
|
|
|
+ # Double-check right before deploying to prevent race conditions
|
|
|
+ if is_compiling():
|
|
|
+ msg = quote("Deployment blocked: A build is currently in progress.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
|
|
|
@@ -270,10 +382,14 @@ async def upload_file(
|
|
|
):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
- prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
- if prof and prof['is_readonly']:
|
|
|
- msg = quote("Cannot upload files to a read-only profile.")
|
|
|
- return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+ prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
|
|
|
+ if prof:
|
|
|
+ if prof['is_readonly']:
|
|
|
+ msg = quote("Cannot upload files to a read-only profile.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+ if prof['password']:
|
|
|
+ msg = quote("Cannot upload files to a locked profile.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
|
|
|
target_path = new_path.strip() if new_path.strip() else existing_path.strip()
|
|
|
clean_path = target_path.strip("/")
|
|
|
@@ -302,6 +418,13 @@ async def upload_file(
|
|
|
async def delete_file(file_id: int = Form(...)):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
+ file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
|
+ if file_record:
|
|
|
+ prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
|
|
|
+ if prof and (prof['is_readonly'] or prof['password']):
|
|
|
+ msg = quote("Cannot delete file from a locked or read-only profile.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
|
|
|
+
|
|
|
conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
|
|
|
conn.commit()
|
|
|
return RedirectResponse(url=f"/patch-manager/", status_code=303)
|
|
|
@@ -314,8 +437,18 @@ async def edit_file(request: Request, file_id: int, error_msg: str = None):
|
|
|
try:
|
|
|
with get_db_connection() as conn:
|
|
|
file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
|
- active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
|
|
|
- profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
|
|
|
+
|
|
|
+ active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
|
|
|
+ active_profile = dict(active_profile_raw) if active_profile_raw else None
|
|
|
+ if active_profile:
|
|
|
+ active_profile['is_locked'] = bool(active_profile.get('password'))
|
|
|
+
|
|
|
+ profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
|
|
|
+ profiles = []
|
|
|
+ for p in profiles_raw:
|
|
|
+ p_dict = dict(p)
|
|
|
+ p_dict['is_locked'] = bool(p_dict.get('password'))
|
|
|
+ profiles.append(p_dict)
|
|
|
|
|
|
if not file_record:
|
|
|
msg = quote("File not found in database.")
|
|
|
@@ -346,6 +479,13 @@ async def save_file(file_id: int = Form(...), content: str = Form(...)):
|
|
|
try:
|
|
|
content_bytes = content.encode('utf-8')
|
|
|
with get_db_connection() as conn:
|
|
|
+ file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
|
+ if file_record:
|
|
|
+ prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
|
|
|
+ if prof and (prof['is_readonly'] or prof['password']):
|
|
|
+ msg = quote("Cannot modify files in a locked or read-only profile.")
|
|
|
+ return RedirectResponse(url=f"/patch-manager/edit?file_id={file_id}&error_msg={msg}", status_code=303)
|
|
|
+
|
|
|
conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
|
|
|
conn.commit()
|
|
|
|