main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import os
  2. import sqlite3
  3. import shutil
  4. import tempfile
  5. import logging
  6. from urllib.parse import quote
  7. from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
  8. from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
  9. from fastapi.templating import Jinja2Templates
  10. from fastapi.staticfiles import StaticFiles
  11. # Setup logging
  12. logging.basicConfig(level=logging.INFO)
  13. logger = logging.getLogger("overlay-manager")
  14. app = FastAPI(root_path="/patch-manager")
  15. os.makedirs("static", exist_ok=True)
  16. app.mount("/static", StaticFiles(directory="static"), name="static")
  17. templates = Jinja2Templates(directory="templates")
  18. OVERLAY_DIR = os.path.abspath("/srv")
  19. # Use the new mounted directory for the database
  20. DB_DIR = "/app/db"
  21. os.makedirs(DB_DIR, exist_ok=True)
  22. DB_PATH = os.path.join(DB_DIR, "overlays.db")
  23. def init_db():
  24. """Initializes the SQLite database and schema."""
  25. with sqlite3.connect(DB_PATH) as conn:
  26. conn.execute("""
  27. CREATE TABLE IF NOT EXISTS profiles (
  28. id INTEGER PRIMARY KEY AUTOINCREMENT,
  29. name TEXT UNIQUE NOT NULL,
  30. is_active BOOLEAN NOT NULL DEFAULT 0
  31. )
  32. """)
  33. conn.execute("""
  34. CREATE TABLE IF NOT EXISTS files (
  35. id INTEGER PRIMARY KEY AUTOINCREMENT,
  36. profile_id INTEGER NOT NULL,
  37. filepath TEXT NOT NULL,
  38. content BLOB NOT NULL,
  39. FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
  40. UNIQUE(profile_id, filepath)
  41. )
  42. """)
  43. # Auto-migrate: Add description and is_readonly columns if they don't exist
  44. cursor = conn.cursor()
  45. cursor.execute("PRAGMA table_info(profiles)")
  46. columns = [info[1] for info in cursor.fetchall()]
  47. if "description" not in columns:
  48. cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
  49. if "is_readonly" not in columns:
  50. cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
  51. # Create default profiles if the DB is empty
  52. cursor.execute("SELECT count(*) FROM profiles")
  53. if cursor.fetchone()[0] == 0:
  54. cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES ('Vanilla (No Overlays)', 'Baseline ArduPilot build without any modifications. Read-only.', 1, 1)")
  55. cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES ('Custom Build Alpha', 'Experimental tuning parameters.', 0, 0)")
  56. else:
  57. # Lock the existing Vanilla profile for users upgrading
  58. cursor.execute("UPDATE profiles SET is_readonly = 1 WHERE name = 'Vanilla (No Overlays)'")
  59. conn.commit()
  60. init_db()
  61. def get_db_connection():
  62. conn = sqlite3.connect(DB_PATH)
  63. conn.row_factory = sqlite3.Row
  64. return conn
  65. @app.get("/", response_class=HTMLResponse)
  66. async def index(request: Request, success_msg: str = None, error_msg: str = None):
  67. conn = get_db_connection()
  68. profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
  69. active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
  70. files = []
  71. dirs = set()
  72. if active_profile:
  73. files = conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()
  74. for f in files:
  75. dirname = os.path.dirname(f['filepath'])
  76. if dirname:
  77. dirs.add(dirname)
  78. conn.close()
  79. return templates.TemplateResponse("index.html", {
  80. "request": request,
  81. "profiles": profiles,
  82. "active_profile": active_profile,
  83. "files": files,
  84. "dirs": sorted(list(dirs)),
  85. "success_msg": success_msg,
  86. "error_msg": error_msg
  87. })
  88. @app.post("/profile/create")
  89. async def create_profile(name: str = Form(...)):
  90. try:
  91. with get_db_connection() as conn:
  92. conn.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, '', 0, 0)", (name.strip(),))
  93. conn.commit()
  94. msg = quote(f"Profile '{name}' created.")
  95. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  96. except sqlite3.IntegrityError:
  97. msg = quote("A profile with that name already exists.")
  98. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  99. except Exception as e:
  100. logger.error(f"Profile creation failed: {e}")
  101. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to create profile.", status_code=303)
  102. @app.post("/profile/rename")
  103. async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
  104. try:
  105. with get_db_connection() as conn:
  106. prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
  107. if prof and prof['is_readonly']:
  108. return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
  109. conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
  110. conn.commit()
  111. msg = quote(f"Profile renamed to '{new_name}'.")
  112. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  113. except sqlite3.IntegrityError:
  114. msg = quote("A profile with that name already exists.")
  115. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  116. except Exception as e:
  117. logger.error(f"Profile rename failed: {e}")
  118. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to rename profile.", status_code=303)
  119. @app.post("/profile/annotate")
  120. async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
  121. try:
  122. with get_db_connection() as conn:
  123. prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
  124. if prof and prof['is_readonly']:
  125. return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
  126. conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
  127. conn.commit()
  128. msg = quote("Notes saved successfully.")
  129. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  130. except Exception as e:
  131. logger.error(f"Annotation failed: {e}")
  132. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to save notes.", status_code=303)
  133. @app.post("/profile/duplicate")
  134. async def duplicate_profile(profile_id: int = Form(...)):
  135. try:
  136. with get_db_connection() as conn:
  137. cursor = conn.cursor()
  138. src = cursor.execute("SELECT name, description FROM profiles WHERE id = ?", (profile_id,)).fetchone()
  139. if not src:
  140. raise Exception("Source profile not found.")
  141. base_name = src['name']
  142. desc = src['description'] if src['description'] else ""
  143. new_name = f"{base_name} (Copy)"
  144. counter = 1
  145. while True:
  146. exists = cursor.execute("SELECT id FROM profiles WHERE name = ?", (new_name,)).fetchone()
  147. if not exists:
  148. break
  149. counter += 1
  150. new_name = f"{base_name} (Copy {counter})"
  151. cursor.execute("UPDATE profiles SET is_active = 0")
  152. # Duplicates are NEVER read-only, even if cloned from a read-only profile
  153. cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, ?, 1, 0)", (new_name, desc))
  154. new_profile_id = cursor.lastrowid
  155. files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
  156. for f in files:
  157. cursor.execute("INSERT INTO files (profile_id, filepath, content) VALUES (?, ?, ?)",
  158. (new_profile_id, f['filepath'], f['content']))
  159. conn.commit()
  160. msg = quote(f"Seamlessly cloned and activated '{new_name}'.")
  161. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  162. except Exception as e:
  163. logger.error(f"Profile duplication failed: {e}")
  164. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to duplicate profile.", status_code=303)
  165. @app.post("/profile/activate")
  166. async def activate_profile(profile_id: int = Form(...)):
  167. try:
  168. with get_db_connection() as conn:
  169. conn.execute("UPDATE profiles SET is_active = 0")
  170. conn.execute("UPDATE profiles SET is_active = 1 WHERE id = ?", (profile_id,))
  171. conn.commit()
  172. return RedirectResponse(url=f"/patch-manager/", status_code=303)
  173. except Exception as e:
  174. logger.error(f"Profile activation failed: {e}")
  175. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to activate profile.", status_code=303)
  176. @app.post("/profile/delete")
  177. async def delete_profile(profile_id: int = Form(...)):
  178. try:
  179. with get_db_connection() as conn:
  180. prof = conn.execute("SELECT is_readonly, is_active FROM profiles WHERE id = ?").fetchone()
  181. if prof and prof['is_readonly']:
  182. msg = quote("Cannot delete a read-only profile.")
  183. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  184. if prof and prof['is_active']:
  185. msg = quote("Cannot delete the currently active profile. Switch to another first.")
  186. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  187. conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
  188. conn.commit()
  189. msg = quote("Profile deleted successfully.")
  190. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  191. except Exception as e:
  192. logger.error(f"Profile deletion failed: {e}")
  193. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
  194. @app.post("/deploy")
  195. async def deploy_active_profile():
  196. try:
  197. with get_db_connection() as conn:
  198. active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
  199. if not active:
  200. raise Exception("No active profile selected.")
  201. files = conn.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (active['id'],)).fetchall()
  202. if os.path.exists(OVERLAY_DIR):
  203. for item in os.listdir(OVERLAY_DIR):
  204. item_path = os.path.join(OVERLAY_DIR, item)
  205. if os.path.isfile(item_path):
  206. os.remove(item_path)
  207. elif os.path.isdir(item_path):
  208. shutil.rmtree(item_path)
  209. os.makedirs(OVERLAY_DIR, exist_ok=True)
  210. count = 0
  211. for f in files:
  212. target = os.path.abspath(os.path.join(OVERLAY_DIR, f['filepath'].lstrip("/")))
  213. if target.startswith(OVERLAY_DIR):
  214. os.makedirs(os.path.dirname(target), exist_ok=True)
  215. with open(target, "wb") as out_file:
  216. out_file.write(f['content'])
  217. count += 1
  218. return RedirectResponse(url="/", status_code=303)
  219. except Exception as e:
  220. logger.error(f"Deployment failed: {e}")
  221. msg = quote("Failed to deploy files to staging directory.")
  222. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  223. @app.post("/file/upload")
  224. async def upload_file(
  225. file: UploadFile = File(...),
  226. existing_path: str = Form(""),
  227. new_path: str = Form(""),
  228. profile_id: int = Form(...)
  229. ):
  230. try:
  231. with get_db_connection() as conn:
  232. prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
  233. if prof and prof['is_readonly']:
  234. msg = quote("Cannot upload files to a read-only profile.")
  235. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  236. target_path = new_path.strip() if new_path.strip() else existing_path.strip()
  237. clean_path = target_path.strip("/")
  238. safe_filename = os.path.basename(file.filename)
  239. full_filepath = os.path.join(clean_path, safe_filename) if clean_path else safe_filename
  240. content = await file.read()
  241. conn.execute("""
  242. INSERT INTO files (profile_id, filepath, content)
  243. VALUES (?, ?, ?)
  244. ON CONFLICT(profile_id, filepath)
  245. DO UPDATE SET content=excluded.content
  246. """, (profile_id, full_filepath, content))
  247. conn.commit()
  248. msg = quote(f"Successfully saved {safe_filename} to profile.")
  249. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  250. except Exception as e:
  251. logger.error(f"Upload failed: {e}")
  252. msg = quote("Failed to upload file to database.")
  253. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  254. @app.post("/file/delete")
  255. async def delete_file(file_id: int = Form(...)):
  256. try:
  257. with get_db_connection() as conn:
  258. conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
  259. conn.commit()
  260. return RedirectResponse(url=f"/patch-manager/", status_code=303)
  261. except Exception as e:
  262. logger.error(f"File deletion failed: {e}")
  263. return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete file.", status_code=303)
  264. @app.get("/edit", response_class=HTMLResponse)
  265. async def edit_file(request: Request, file_id: int, error_msg: str = None):
  266. try:
  267. with get_db_connection() as conn:
  268. file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
  269. active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
  270. profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
  271. if not file_record:
  272. msg = quote("File not found in database.")
  273. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  274. try:
  275. content_text = file_record['content'].decode('utf-8')
  276. except UnicodeDecodeError:
  277. msg = quote(f"Safety Lock: {os.path.basename(file_record['filepath'])} appears to be a binary file and cannot be opened in the text editor.")
  278. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  279. return templates.TemplateResponse("edit.html", {
  280. "request": request,
  281. "file_id": file_id,
  282. "filepath": file_record['filepath'],
  283. "content": content_text,
  284. "active_profile": active_profile,
  285. "profiles": profiles,
  286. "error_msg": error_msg
  287. })
  288. except Exception as e:
  289. logger.error(f"Edit GET failed: {e}")
  290. msg = quote(f"System Error loading file: {str(e)}")
  291. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
  292. @app.post("/edit")
  293. async def save_file(file_id: int = Form(...), content: str = Form(...)):
  294. try:
  295. content_bytes = content.encode('utf-8')
  296. with get_db_connection() as conn:
  297. conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
  298. conn.commit()
  299. msg = quote("Successfully saved changes.")
  300. return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
  301. except Exception as e:
  302. logger.error(f"Edit POST failed: {e}")
  303. msg = quote("Failed to save file.")
  304. return RedirectResponse(url=f"/patch-manager/edit?file_id={file_id}&error_msg={msg}", status_code=303)
  305. @app.get("/download")
  306. async def download_single_file(file_id: int):
  307. try:
  308. with get_db_connection() as conn:
  309. file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
  310. if not file_record:
  311. raise HTTPException(status_code=404, detail="File not found")
  312. with tempfile.NamedTemporaryFile(delete=False) as tmp:
  313. tmp.write(file_record['content'])
  314. tmp_path = tmp.name
  315. return FileResponse(
  316. path=tmp_path,
  317. filename=os.path.basename(file_record['filepath']),
  318. media_type='application/octet-stream'
  319. )
  320. except Exception as e:
  321. logger.error(f"Download single file failed: {e}")
  322. msg = quote("Failed to download file.")
  323. return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)