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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user