Enhance first-boot script and documentation for eMMC provisioning: add structured logging, improve package installation process, and implement one-shot autostart for rotation and wallpaper setup. Update dashboard to manage portal file descriptions and enhance admin interface with new navigation links.

This commit is contained in:
nearxos
2026-02-20 08:42:53 +02:00
parent 9c533e95f9
commit 9098e820e6
22 changed files with 861 additions and 206 deletions

View File

@@ -38,6 +38,7 @@ BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json")))
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
@@ -395,15 +396,32 @@ def admin():
return render_template("admin.html", username=session.get("username", "Admin"))
@app.route("/admin/portal-files")
@require_admin
def admin_portal_files():
return render_template("portal_files.html", username=session.get("username", "Admin"))
@app.route("/admin/cloudinit-build")
@require_admin
def admin_cloudinit_build():
return render_template("cloudinit_build.html", username=session.get("username", "Admin"))
# Serve portal files for wget (e.g. cloud-init first boot). No auth.
# Subpaths allowed (e.g. first-boot/splash.png); ".." and "\\" forbidden to prevent traversal.
@app.route("/files/<path:filename>")
def serve_portal_file(filename):
if ".." in filename or "/" in filename or "\\" in filename:
if ".." in filename or "\\" in filename:
return jsonify({"error": "invalid path"}), 400
path = (PORTAL_FILES_DIR / filename).resolve()
try:
path.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"error": "invalid path"}), 400
path = PORTAL_FILES_DIR / filename
if not path.is_file():
return jsonify({"error": "not found"}), 404
return send_file(path, as_attachment=False, download_name=filename)
return send_file(path, as_attachment=False, download_name=filename.split("/")[-1])
@app.route("/api/status")
@@ -571,20 +589,95 @@ def api_cloudinit_images():
return jsonify({"images": list_cloudinit_images(), "cloudinit_dir": str(CLOUDINIT_IMAGES_DIR)})
def _load_portal_descriptions():
"""Return dict mapping path -> description (path can be file or folder)."""
if not PORTAL_DESCRIPTIONS_FILE.is_file():
return {}
try:
with open(PORTAL_DESCRIPTIONS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {}
def _save_portal_descriptions(descriptions):
try:
PORTAL_DESCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PORTAL_DESCRIPTIONS_FILE, "w", encoding="utf-8") as f:
json.dump(descriptions, f, indent=2)
return True
except OSError:
return False
@app.route("/api/portal-files")
@require_admin
def api_portal_files_list():
"""List one level: root or contents of path=... (folders and files)."""
subpath = request.args.get("path", "").strip().strip("/")
if ".." in subpath or "\\" in subpath:
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": ""})
if not PORTAL_FILES_DIR.is_dir():
return jsonify({"files": [], "base_url": request.host_url.rstrip("/") + "/files/"})
files = []
for p in sorted(PORTAL_FILES_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and ".." not in p.name:
try:
files.append({"name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime})
except OSError:
pass
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": ""})
list_dir = (PORTAL_FILES_DIR / subpath).resolve() if subpath else PORTAL_FILES_DIR
try:
list_dir.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": subpath})
if not list_dir.is_dir():
return jsonify({"items": [], "base_url": request.host_url.rstrip("/") + "/files/", "descriptions": {}, "current_path": subpath})
items = []
for p in sorted(list_dir.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
if ".." in p.name or p.name.startswith("."):
continue
rel = (subpath + "/" + p.name) if subpath else p.name
try:
if p.is_dir():
items.append({"type": "folder", "path": rel, "name": p.name})
else:
items.append({"type": "file", "path": rel, "name": p.name, "size": p.stat().st_size, "mtime": p.stat().st_mtime})
except OSError:
pass
descriptions = _load_portal_descriptions()
base = request.host_url.rstrip("/") + "/files/"
return jsonify({"files": files, "base_url": base})
return jsonify({"items": items, "base_url": base, "descriptions": descriptions, "current_path": subpath})
@app.route("/api/portal-files/descriptions", methods=["GET", "PATCH"])
@require_admin
def api_portal_descriptions():
if request.method == "PATCH":
data = request.get_json(force=True, silent=True) or {}
desc = data.get("descriptions")
if not isinstance(desc, dict):
return jsonify({"ok": False, "error": "descriptions must be a dict"}), 400
if not _save_portal_descriptions(desc):
return jsonify({"ok": False, "error": "save failed"}), 500
admin_log("portal_descriptions", "updated")
return jsonify({"ok": True})
return jsonify({"descriptions": _load_portal_descriptions()})
@app.route("/api/portal-files/folder", methods=["POST"])
@require_admin
def api_portal_folder_create():
data = request.get_json(force=True, silent=True) or {}
path = (data.get("path") or "").strip().strip("/")
if not path:
return jsonify({"ok": False, "error": "path required"}), 400
if ".." in path or "\\" in path:
return jsonify({"ok": False, "error": "invalid path"}), 400
full = (PORTAL_FILES_DIR / path).resolve()
try:
full.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"ok": False, "error": "invalid path"}), 400
try:
full.mkdir(parents=True, exist_ok=True)
admin_log("portal_folder_create", path)
return jsonify({"ok": True, "path": path})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/portal-files/upload", methods=["POST"])
@@ -595,12 +688,20 @@ def api_portal_files_upload():
f = request.files.get("file") or request.files.get("upload")
if not f or not f.filename:
return jsonify({"ok": False, "error": "no file selected"}), 400
name = re.sub(r"[^\w\-.]", "_", f.filename)[:120] or "upload"
if ".." in name or name.startswith("/"):
base_name = re.sub(r"[^\w\-./]", "_", f.filename)[:120] or "upload"
if ".." in base_name or base_name.startswith("/"):
return jsonify({"ok": False, "error": "invalid filename"}), 400
path = PORTAL_FILES_DIR / name
subpath = (request.form.get("path") or "").strip().strip("/")
if subpath and (".." in subpath or "\\" in subpath):
return jsonify({"ok": False, "error": "invalid path"}), 400
name = (subpath + "/" + base_name) if subpath else base_name
path = (PORTAL_FILES_DIR / name).resolve()
try:
PORTAL_FILES_DIR.mkdir(parents=True, exist_ok=True)
path.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"ok": False, "error": "invalid path"}), 400
try:
path.parent.mkdir(parents=True, exist_ok=True)
f.save(str(path))
admin_log("portal_upload", name)
return jsonify({"ok": True, "name": name, "url": request.host_url.rstrip("/") + "/files/" + name})
@@ -613,17 +714,30 @@ def api_portal_files_upload():
@app.route("/api/portal-files/<path:name>", methods=["DELETE"])
@require_admin
def api_portal_file_delete(name):
if ".." in name or "/" in name or "\\" in name:
if ".." in name or "\\" in name:
return jsonify({"ok": False, "error": "invalid name"}), 400
path = PORTAL_FILES_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "not found"}), 404
path = (PORTAL_FILES_DIR / name).resolve()
try:
path.unlink()
admin_log("portal_delete", name)
return jsonify({"ok": True})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
path.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"ok": False, "error": "invalid path"}), 400
if path.is_file():
try:
path.unlink()
admin_log("portal_delete", name)
return jsonify({"ok": True})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
if path.is_dir():
if any(path.iterdir()):
return jsonify({"ok": False, "error": "folder not empty"}), 400
try:
path.rmdir()
admin_log("portal_folder_delete", name)
return jsonify({"ok": True})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": False, "error": "not found"}), 404
@app.route("/api/backups/upload", methods=["POST"])