diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 39f2d3a..8f6f06b 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -1063,6 +1063,96 @@ def api_portal_files_upload(): return jsonify({"ok": False, "error": str(e)}), 500 +# Text extensions allowed for editor (others are read-only or binary) +_PORTAL_EDITABLE_EXTENSIONS = frozenset( + ".sh .bash .conf .cfg .ini .desktop .json .py .yml .yaml .md .txt .plymouth .script".split() +) + + +def _portal_language_for_path(name): + """Return Ace/editor language mode from filename.""" + n = (name or "").lower() + if n.endswith(".sh") or n.endswith(".bash"): + return "sh" + if n.endswith(".json"): + return "json" + if n.endswith(".py"): + return "python" + if n.endswith(".yml") or n.endswith(".yaml"): + return "yaml" + if n.endswith(".md"): + return "markdown" + if n.endswith(".conf") or n.endswith(".cfg") or n.endswith(".ini") or n.endswith(".desktop") or n.endswith(".plymouth"): + return "ini" + if n.endswith(".script"): + return "sh" + return "plain_text" + + +@app.route("/api/portal-files/content", methods=["GET"]) +@require_admin +def api_portal_file_content_get(): + """Return file content as text for the editor. path= query param. Rejects binary.""" + path_arg = (request.args.get("path") or "").strip() + if not path_arg or ".." in path_arg or "\\" in path_arg: + return jsonify({"ok": False, "error": "invalid path"}), 400 + path = (PORTAL_FILES_DIR / path_arg).resolve() + try: + path.relative_to(PORTAL_FILES_DIR.resolve()) + except ValueError: + return jsonify({"ok": False, "error": "invalid path"}), 400 + if not path.is_file(): + return jsonify({"ok": False, "error": "not found"}), 404 + ext = path.suffix.lower() if path.suffix else "" + if ext and ext not in _PORTAL_EDITABLE_EXTENSIONS: + return jsonify({"ok": False, "error": "binary or unsupported file type for editing"}), 400 + try: + raw = path.read_bytes() + text = raw.decode("utf-8") + except UnicodeDecodeError: + return jsonify({"ok": False, "error": "binary or unsupported encoding"}), 400 + except OSError as e: + return jsonify({"ok": False, "error": str(e)}), 500 + return jsonify({ + "ok": True, + "path": path_arg, + "content": text, + "language": _portal_language_for_path(path.name), + }) + + +@app.route("/api/portal-files/content", methods=["PUT"]) +@require_admin +def api_portal_file_content_put(): + """Write file content from editor. JSON body: { \"path\": \"...\", \"content\": \"...\" }.""" + data = request.get_json(silent=True) or {} + path_arg = (data.get("path") or "").strip() + content = data.get("content") + if not path_arg or ".." in path_arg or "\\" in path_arg: + return jsonify({"ok": False, "error": "invalid path"}), 400 + if content is None: + return jsonify({"ok": False, "error": "content required"}), 400 + path = (PORTAL_FILES_DIR / path_arg).resolve() + try: + path.relative_to(PORTAL_FILES_DIR.resolve()) + except ValueError: + return jsonify({"ok": False, "error": "invalid path"}), 400 + if path.is_dir(): + return jsonify({"ok": False, "error": "cannot edit a folder"}), 400 + ext = path.suffix.lower() if path.suffix else "" + if path.exists() and ext and ext not in _PORTAL_EDITABLE_EXTENSIONS: + return jsonify({"ok": False, "error": "unsupported file type for editing"}), 400 + if not isinstance(content, str): + content = str(content) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + admin_log("portal_edit", path_arg) + return jsonify({"ok": True, "path": path_arg}) + except OSError as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + @app.route("/api/portal-files/", methods=["DELETE"]) @require_admin def api_portal_file_delete(name): diff --git a/emmc-provisioning/dashboard/templates/portal_files.html b/emmc-provisioning/dashboard/templates/portal_files.html index eacb8f5..90ea12e 100644 --- a/emmc-provisioning/dashboard/templates/portal_files.html +++ b/emmc-provisioning/dashboard/templates/portal_files.html @@ -47,6 +47,15 @@ .desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; } .folder-name { font-weight: 500; } input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; } + /* Editor modal */ + .editor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 1rem; } + .editor-overlay.visible { display: flex; } + .editor-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 900px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } + .editor-header { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } + .editor-title { font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; color: var(--text); } + .editor-actions { display: flex; gap: 0.5rem; } + .editor-body { flex: 1; min-height: 400px; overflow: hidden; } + #editorHost { width: 100%; height: 100%; min-height: 420px; } @@ -80,8 +89,24 @@ + +
+
+
+ +
+ + +
+
+
+
+
+
+
+