Package Manager (new app): - Search, install, remove apt packages via web UI - Backend: apt-cache/dpkg-query/apt-get wrapper with input validation - Frontend: searchable package list with expandable detail panels Text Editor / File Viewer (new app): - Opens from file manager, supports text editing with line numbers - Image preview via authenticated blob URLs - Binary file info display with download option - Ctrl+S / Cmd+S save, dirty tracking, tab key support File Manager enhancements: - Toolbar: New File, New Folder, Upload, Delete, Refresh buttons - Context menu: New File/Folder options, Open in Editor - Double-click files to open in editor - Right-click empty area for create options Auto-update notification: - Backend checks git repo for new commits (fetch + compare) - One-click update: git pull + pip install + service restart - Toast notification in right panel with dismiss option - Polls every 30 minutes, retry logic for server restart Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
6.3 KiB
Python
199 lines
6.3 KiB
Python
"""Package management — apt-get / dpkg wrapper for Debian-based systems."""
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import shutil
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
|
|
from backend.auth import get_current_user
|
|
|
|
router = APIRouter(prefix="/api/packages", tags=["packages"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HAS_APT = bool(shutil.which("apt-cache"))
|
|
|
|
|
|
def _require_apt():
|
|
if not _HAS_APT:
|
|
raise HTTPException(503, "apt package manager not available on this system")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PKG_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9.+\-:]+$")
|
|
|
|
|
|
def _validate_name(name: str) -> str:
|
|
name = name.strip()
|
|
if not _PKG_NAME_RE.match(name):
|
|
raise HTTPException(400, f"Invalid package name: {name}")
|
|
return name
|
|
|
|
|
|
async def _apt(*args: str, timeout: float = 30) -> str:
|
|
"""Run an apt/dpkg command and return stdout."""
|
|
_require_apt()
|
|
cmd = list(args)
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"},
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
raise HTTPException(504, "Package operation timed out")
|
|
if proc.returncode != 0:
|
|
msg = stderr.decode().strip() or stdout.decode().strip()
|
|
raise HTTPException(500, f"Package error: {msg}")
|
|
return stdout.decode()
|
|
|
|
|
|
async def _apt_nofail(*args: str, timeout: float = 30) -> tuple[int, str, str]:
|
|
"""Run an apt/dpkg command, return (returncode, stdout, stderr) without raising."""
|
|
_require_apt()
|
|
cmd = list(args)
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"},
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.wait()
|
|
return 1, "", "Timed out"
|
|
return proc.returncode, stdout.decode(), stderr.decode()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PackageAction(BaseModel):
|
|
name: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/search")
|
|
async def search_packages(
|
|
q: str = Query(..., min_length=2, description="Search query"),
|
|
_user: str = Depends(get_current_user),
|
|
):
|
|
"""Search for packages matching a query."""
|
|
output = await _apt("apt-cache", "search", q)
|
|
results = []
|
|
for line in output.strip().splitlines():
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split(" - ", 1)
|
|
if len(parts) == 2:
|
|
results.append({"name": parts[0].strip(), "summary": parts[1].strip()})
|
|
if len(results) >= 100:
|
|
break
|
|
return results
|
|
|
|
|
|
@router.get("/info/{name}")
|
|
async def package_info(
|
|
name: str,
|
|
_user: str = Depends(get_current_user),
|
|
):
|
|
"""Get detailed information about a package."""
|
|
name = _validate_name(name)
|
|
|
|
# Get package info from apt-cache
|
|
output = await _apt("apt-cache", "show", name)
|
|
|
|
# Parse key-value fields
|
|
info = {}
|
|
current_key = None
|
|
current_val = ""
|
|
for line in output.splitlines():
|
|
if line.startswith(" "):
|
|
# Continuation of previous field
|
|
current_val += "\n" + line.strip()
|
|
elif ": " in line:
|
|
if current_key:
|
|
info[current_key] = current_val
|
|
current_key, current_val = line.split(": ", 1)
|
|
elif line == "":
|
|
if current_key:
|
|
info[current_key] = current_val
|
|
current_key = None
|
|
current_val = ""
|
|
break # Only parse first stanza
|
|
if current_key:
|
|
info[current_key] = current_val
|
|
|
|
# Check installed status
|
|
rc, dpkg_out, _ = await _apt_nofail(
|
|
"dpkg-query", "-W", "-f=${Status}\t${Version}\n", name
|
|
)
|
|
installed_version = None
|
|
is_installed = False
|
|
if rc == 0 and dpkg_out.strip():
|
|
parts = dpkg_out.strip().split("\t")
|
|
if len(parts) >= 2 and "install ok installed" in parts[0]:
|
|
is_installed = True
|
|
installed_version = parts[1]
|
|
|
|
return {
|
|
"name": info.get("Package", name),
|
|
"version": info.get("Version", ""),
|
|
"installed": is_installed,
|
|
"installed_version": installed_version,
|
|
"description": info.get("Description", info.get("Description-en", "")),
|
|
"size": info.get("Size", ""),
|
|
"installed_size": info.get("Installed-Size", ""),
|
|
"depends": info.get("Depends", ""),
|
|
"section": info.get("Section", ""),
|
|
"maintainer": info.get("Maintainer", ""),
|
|
"homepage": info.get("Homepage", ""),
|
|
"architecture": info.get("Architecture", ""),
|
|
}
|
|
|
|
|
|
@router.post("/install")
|
|
async def install_package(
|
|
req: PackageAction,
|
|
_user: str = Depends(get_current_user),
|
|
):
|
|
"""Install a package via apt-get."""
|
|
name = _validate_name(req.name)
|
|
output = await _apt("apt-get", "install", "-y", name, timeout=300)
|
|
return {"success": True, "package": name, "output": output[-500:]}
|
|
|
|
|
|
@router.post("/remove")
|
|
async def remove_package(
|
|
req: PackageAction,
|
|
_user: str = Depends(get_current_user),
|
|
):
|
|
"""Remove a package via apt-get."""
|
|
name = _validate_name(req.name)
|
|
output = await _apt("apt-get", "remove", "-y", name, timeout=120)
|
|
return {"success": True, "package": name, "output": output[-500:]}
|
|
|
|
|
|
@router.post("/update-cache")
|
|
async def update_cache(_user: str = Depends(get_current_user)):
|
|
"""Run apt-get update to refresh package lists."""
|
|
output = await _apt("apt-get", "update", timeout=120)
|
|
return {"success": True, "output": output[-500:]}
|