"""Server Up — Docker Manager.""" from __future__ import annotations import inspect, json, os, re, shutil, subprocess, sys, time from pathlib import Path # Ensure app dir is in sys.path so modules can import 'from modules.base' and 'from core' APP = Path(__file__).resolve().parent if str(APP) not in sys.path: sys.path.insert(0, str(APP)) import yaml from flask import Flask, Response, jsonify, render_template, request import core as cfg from core import audit, jobs, i18n from core import docker, git, boilerplates from core.modules import Module, CORE, discover app = Flask(__name__, static_folder="static", template_folder="templates") VERSION = os.environ.get("SU_VERSION", "0.3.0") MODULES: list[Module] = [] USER_MOD = APP / "modules" BUNDLED_MOD = APP.parent / "modules" CONTAINER_NAME = os.environ.get("HOSTNAME", os.environ.get("SU_CONTAINER", "server-up")) # ── Module loading ──────────────────────────────────────────────────────────── def _mod_js(m: Module) -> Path | None: sd = getattr(m, "_src", None) if sd: f = Path(sd) / "static" / "module.js" if f.exists(): return f return None STARTUP_LOG = [] def load_modules(): # Stap 1: Ruim eerder geïnstalleerde CORE modules op uit user_modules if USER_MOD.exists(): for d in list(USER_MOD.iterdir()): if d.is_dir() and d.name in CORE: msg = f"↻ {d.name} (core, verwijderd uit user_modules)" print(f" {msg}") STARTUP_LOG.append(msg) shutil.rmtree(d, ignore_errors=True) elif d.is_dir(): mf = d / "module.json" if mf.exists(): try: meta = json.loads(mf.read_text()) if meta.get("id", d.name) in CORE: msg = f"↻ {d.name} (core id={meta['id']}, verwijderd)" print(f" {msg}") STARTUP_LOG.append(msg) shutil.rmtree(d, ignore_errors=True) except Exception: pass # Stap 2: Laad optionele modules active = cfg.load().get("ACTIVE_MODULES") for mid, cls, d in discover([USER_MOD, BUNDLED_MOD]): if active is not None and mid not in active: msg = f"○ {mid} (uit)" print(f" {msg}") STARTUP_LOG.append(msg) continue try: inst = cls() inst._src = d if inst.ID in CORE: msg = f"○ {mid} (core, overgeslagen)" print(f" {msg}") STARTUP_LOG.append(msg) continue MODULES.append(inst) bp = inst.blueprint() if bp: try: app.register_blueprint(bp) msg = f"✔ {mid} (met routes)" except Exception as e: msg = f"⚠ {mid} (routes fout: {e})" else: msg = f"✔ {mid} (geen routes)" print(f" {msg}") STARTUP_LOG.append(msg) inst.on_load(app) except Exception as e: msg = f"✖ {mid}: {e}" print(f" {msg}") STARTUP_LOG.append(msg) def _notify_restart(mid: str) -> tuple[bool, str]: """Module is geïnstalleerd; vereist herstart om actief te worden.""" return True, "geïnstalleerd — herstart de container om te activeren" # ══════════════════════════════════════════════════════════════════════════════ # API # ══════════════════════════════════════════════════════════════════════════════ @app.route("/") def index(): resp = render_template("index.html") return resp @app.after_request def no_cache(response): if request.path == "/" or request.path.endswith(".html"): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" return response # ── Docker ──────────────────────────────────────────────────────────────────── @app.route("/api/docker/info") def api_info(): d = docker.info() d["su_version"] = VERSION return jsonify(d) @app.route("/api/version") def api_version(): return jsonify(version=VERSION) @app.route("/api/docker/images") def api_images(): return jsonify(images=docker.images()) @app.route("/api/docker/images/remove", methods=["POST"]) def api_images_rm(): d = request.json or {} ok, msg = docker.rmi(d.get("id", ""), d.get("force", False)) if ok: audit.log("docker", "rmi", "ok", detail=d.get("id")) return jsonify(ok=ok, msg=msg) @app.route("/api/docker/images/prune", methods=["POST"]) def api_images_prune(): ok, msg = docker.prune_images() if ok: audit.log("docker", "prune", "ok") return jsonify(ok=ok, msg=msg) @app.route("/api/docker/restart", methods=["GET", "POST"]) def api_restart(): """Restart the Server Up container itself.""" audit.log("core", "restart", "ok", ip=request.remote_addr) ok, msg = docker.restart_container(CONTAINER_NAME) return jsonify(ok=ok, msg=msg) # ── Projects / Stacks ──────────────────────────────────────────────────────── @app.route("/api/stacks") def api_stacks(): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() if not lib.exists(): return jsonify([]) # Bescherm tegen verkeerde LIBRARY_DIR if lib == Path("/app").resolve(): return jsonify([]) out = [] for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith("."): continue if not docker.has_compose(d): continue ct = docker.compose_ps(d, name=d.name) out.append({ "name": d.name, "path": str(d), "has_compose": True, "running": any(c["running"] for c in ct), "containers": ct, }) return jsonify(out) @app.route("/api/stacks//", methods=["POST"]) def api_stack_action(name, action): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 if not d.exists(): return jsonify(ok=False, msg="niet gevonden"), 404 if action not in ("start", "stop", "restart", "update", "remove", "backup"): return jsonify(ok=False, msg="onbekend"), 400 body = request.json or {} jid, q = jobs.create(f"{action}:{name}") def _run(jid): qq = jobs.get_queue(jid) pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name lf = lambda m: jobs.log(qq, "dim", m) try: jobs.log(qq, "section", f"{action}: {name}") rc = 0 if action == "start": rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "stop": rc = docker.compose_down(pdir, log_fn=lf, name=name) elif action == "restart": docker.compose_down(pdir, log_fn=lf, name=name) rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "update": docker.compose_pull(pdir, log_fn=lf) rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "remove": mode = body.get("mode", "stack") if docker.has_compose(pdir): docker.compose_down(pdir, log_fn=lf, volumes=True, name=name) if mode in ("full", "all") and pdir.is_dir(): shutil.rmtree(pdir) jobs.log(qq, "ok", "Bestanden verwijderd") if mode == "all": dd = Path(cfg.load()["DATA_DIR"]).resolve() / name if dd.is_dir(): shutil.rmtree(dd) jobs.log(qq, "ok", "Data verwijderd") elif action == "backup": bd = Path(cfg.load()["BACKUP_DIR"]).resolve() bd.mkdir(parents=True, exist_ok=True) ts = time.strftime("%Y%m%d_%H%M%S") safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"), "-C", str(pdir.parent), name], capture_output=True, timeout=300) jobs.log(qq, "ok", f"Backup → {bd / f'{safe_name}_{ts}.tar.gz'}") jobs.log(qq, "ok" if rc == 0 else "error", f"{action} {'klaar' if rc == 0 else 'mislukt'}") audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name) jobs.finish(jid, "done" if rc == 0 else "error") except Exception as e: jobs.log(qq, "error", str(e)) jobs.finish(jid, "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) @app.route("/api/stacks//env") def api_stack_env(name): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 return jsonify(content=docker.read_env(d)) @app.route("/api/stacks//env", methods=["PUT"]) def api_stack_env_put(name): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 docker.write_env(d, (request.json or {}).get("content", "")) return jsonify(ok=True) @app.route("/api/stacks//compose") def api_stack_compose(name): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 return jsonify(content=docker.read_compose(d)) @app.route("/api/stacks//compose", methods=["PUT"]) def api_stack_compose_put(name): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 docker.write_compose(d, (request.json or {}).get("content", "")) return jsonify(ok=True) @app.route("/api/stacks//logs") def api_stack_logs(name): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() d = (lib / name).resolve() if not d.is_relative_to(lib): return jsonify(ok=False, msg="ongeldig pad"), 400 return jsonify(logs=docker.compose_logs(d, name=name)) # ── App Store ───────────────────────────────────────────────────────────────── @app.route("/api/repos") def api_repos(): repos = cfg.load().get("APP_REPOS", []) result = [{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos] return jsonify(result) @app.route("/api/repos", methods=["POST"]) def api_repos_add(): d = request.json or {} url = d.get("url", "").strip() if not url: return jsonify(ok=False, msg="URL vereist") repos = list(cfg.load().get("APP_REPOS", [])) rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time())) repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1], "url": url, "branch": d.get("branch", "main"), "token": d.get("token", ""), "subdir": d.get("subdir", "")}) cfg.patch({"APP_REPOS": repos}) audit.log("repos", "add", "ok", ref=rid) return jsonify(ok=True) @app.route("/api/repos/", methods=["DELETE"]) def api_repos_del(rid): repos = [r for r in cfg.load().get("APP_REPOS", []) if r["id"] != rid] cfg.patch({"APP_REPOS": repos}) c = git.cache_dir(rid) if c.exists(): shutil.rmtree(c) audit.log("repos", "delete", "ok", ref=rid) return jsonify(ok=True) @app.route("/api/repos//sync", methods=["GET", "POST"]) def api_repos_sync(rid): repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None) if not repo: return jsonify(ok=False, msg="niet gevonden") jid, q = jobs.create(f"sync:{rid}") def _run(jid): qq = jobs.get_queue(jid) ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if ok else "error", msg) jobs.finish(jid, "done" if ok else "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) @app.route("/api/repos//stacks") def api_repos_stacks(rid): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None) if not repo: return jsonify(stacks=[]) return jsonify(stacks=git.scan_stacks(rid, lib, subdir=repo.get("subdir", ""))) def _is_stack_dir(p: Path) -> bool: """True als de map ofwel een legacy compose-stack ofwel een boilerplate is.""" return docker.has_compose(p) or boilerplates.is_boilerplate(p) def _find_stack_src(stack: str, rid: str) -> Path | None: """Zoek de stack bronmap in de git cache.""" repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None) if not repo: return None cache_root = git.cache_dir(rid) # Poging 1: verwacht pad expected = cache_root if repo.get("subdir"): expected = expected / repo["subdir"] expected = expected / stack if expected.exists() and _is_stack_dir(expected): return expected # Poging 2: bekende submappen for fb in ("apps", "stacks", "docker", "compose"): p = cache_root / fb / stack if p.exists() and _is_stack_dir(p): return p # Poging 3: direct in root direct = cache_root / stack if direct.exists() and _is_stack_dir(direct): return direct # Poging 4: recursief if cache_root.exists(): for p in cache_root.rglob("*"): if p.is_dir() and p.name == stack and ".git" not in str(p) and _is_stack_dir(p): return p return None @app.route("/api/store/preview", methods=["POST"]) def api_store_preview(): """Preview een stack: lees compose + .env en extract configureerbare velden. Voor boilerplate-stacks wordt de variabelen-schema uit template.json teruggegeven zodat de install-modal een rijk formulier kan tonen.""" d = request.json or {} stack = d.get("stack", "").strip() rid = d.get("repo_id", "") if not stack or not rid: return jsonify(ok=False, msg="stack en repo_id vereist") src = _find_stack_src(stack, rid) if not src: return jsonify(ok=False, msg=f"'{stack}' niet gevonden") # ── Boilerplate (ChristianLempa-formaat) ────────────────────────────────── if boilerplates.is_boilerplate(src): meta = boilerplates.metadata(src) flds = boilerplates.fields(src) # Render een preview met defaults, zodat de gebruiker ziet wat er komt try: ctx = boilerplates.build_context(src, {}) files_dir = src / "files" preview_files = {} for p in sorted(files_dir.rglob("*")): if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000: preview_files[p.relative_to(files_dir).as_posix()] = \ boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:4000] except Exception as e: preview_files = {"_error": f"render: {e}"} return jsonify( ok=True, stack=stack, repo_id=rid, format="boilerplate", metadata=meta, fields=flds, preview_files=preview_files, # Backwards-compat voor de oude UI: env_vars=[], images=[], ports=[], compose_preview=preview_files.get("compose.yaml", "") or preview_files.get("docker-compose.yml", ""), env_preview="", ) # ── Legacy Server Up formaat (compose.yml + .env) ───────────────────────── result = {"stack": stack, "repo_id": rid, "format": "compose", "env_vars": [], "images": [], "ports": []} # Parse .env ef = src / ".env" if ef.exists(): for line in ef.read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: k, v = line.split("=", 1) k, v = k.strip(), v.strip() is_port = "PORT" in k.upper() is_tag = k.upper() in ("TAG", "VERSION", "IMAGE_TAG", "IMAGE_VERSION") or k.upper().endswith("_TAG") or k.upper().endswith("_VERSION") result["env_vars"].append({ "key": k, "value": v, "is_port": is_port, "is_tag": is_tag, "desc": "Poort" if is_port else "Image tag" if is_tag else "", }) if is_port: try: result["ports"].append({"key": k, "value": int(v)}) except ValueError: result["ports"].append({"key": k, "value": v}) # Parse compose voor images cf = docker.find_compose(src) if cf: try: compose = yaml.safe_load(cf.read_text()) for svc_name, svc in (compose.get("services") or {}).items(): img = svc.get("image", "") if img: repo_part = img.split(":")[0] if ":" in img else img tag_part = img.split(":")[-1] if ":" in img else "latest" result["images"].append({ "service": svc_name, "image": img, "repo": repo_part, "tag": tag_part, }) # Also check ports in compose for p in svc.get("ports", []): ps = str(p) if ":" in ps: host_port = ps.split(":")[0].strip().strip('"').strip("'") # Check if it's a variable reference if "$" not in host_port: try: result["ports"].append({"key": f"{svc_name}_port", "value": int(host_port), "from_compose": True}) except ValueError: pass except Exception: # parse error — try regex content = cf.read_text() for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content): img = m.group(1) repo_part = img.split(":")[0] if ":" in img else img tag_part = img.split(":")[-1] if ":" in img else "latest" if "${" not in img: # Skip variable references result["images"].append({"service": "?", "image": img, "repo": repo_part, "tag": tag_part}) # Compose bestand inhoud voor preview if cf: result["compose_preview"] = cf.read_text()[:2000] result["env_preview"] = ef.read_text()[:1000] if ef.exists() else "" result["ok"] = True return jsonify(result) @app.route("/api/store/install", methods=["POST"]) def api_store_install(): d = request.json or {} stack = d.get("stack", "").strip() inst = d.get("instance", "").strip() or stack rid = d.get("repo_id", "") env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"} image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"} bp_values = d.get("values", {}) # boilerplate variable values if not stack: return jsonify(ok=False, msg="stack vereist") src = _find_stack_src(stack, rid) if not src: return jsonify(ok=False, msg=f"'{stack}' niet gevonden") is_bp = boilerplates.is_boilerplate(src) if not is_bp and not docker.has_compose(src): return jsonify(ok=False, msg=f"'{stack}' heeft geen compose bestand") lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() if lib == Path("/app").resolve(): return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.") try: lib.mkdir(parents=True, exist_ok=True) except Exception as e: return jsonify(ok=False, msg=f"Library map kan niet aangemaakt worden: {e}") dest = (lib / inst).resolve() if dest.exists(): return jsonify(ok=False, msg=f"'{inst}' bestaat al") jid, q = jobs.create(f"install:{inst}") def _run(jid): qq = jobs.get_queue(jid) try: jobs.log(qq, "section", f"Installeren: {inst}") try: dest.mkdir(parents=True, exist_ok=True) except PermissionError: jobs.log(qq, "error", f"Geen schrijfrechten op '{dest}'") jobs.finish(jid, "error"); jobs.done(qq); return if is_bp: # ── Boilerplate: render variabelen → compose-bestand ────────── values = dict(bp_values or {}) # Defaults voor service_name als de gebruiker niets opgaf values.setdefault("service_name", inst) jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)") for k, v in sorted(values.items()): jobs.log(qq, "dim", f" {k}={v}") try: written = boilerplates.render_to_dir(src, dest, values) for rel in written: jobs.log(qq, "dim", f" → {rel}") jobs.log(qq, "ok", f"Gerenderd ({len(written)} bestanden)") except boilerplates.BoilerplateError as e: jobs.log(qq, "error", str(e)) jobs.finish(jid, "error"); jobs.done(qq); return else: # ── Legacy: copy verbatim ───────────────────────────────────── shutil.copytree(str(src), str(dest), dirs_exist_ok=True) jobs.log(qq, "ok", f"Gekopieerd → {dest}") # .env aanpassen met overrides ef = dest / ".env" if ef.exists() and env_overrides: lines = ef.read_text().splitlines() new_lines = [] applied = set() for line in lines: if "=" in line and not line.strip().startswith("#"): k = line.split("=", 1)[0].strip() if k in env_overrides: new_lines.append(f"{k}={env_overrides[k]}") applied.add(k) jobs.log(qq, "dim", f" {k}={env_overrides[k]}") continue new_lines.append(line) # Voeg nieuwe keys toe die niet in het origineel stonden for k, v in env_overrides.items(): if k not in applied: new_lines.append(f"{k}={v}") jobs.log(qq, "dim", f" {k}={v} (nieuw)") ef.write_text("\n".join(new_lines) + "\n") jobs.log(qq, "ok", f"Env geconfigureerd ({len(env_overrides)} waarden)") elif not ef.exists() and env_overrides: # Maak .env aan ef.write_text("\n".join(f"{k}={v}" for k, v in env_overrides.items()) + "\n") jobs.log(qq, "ok", f".env aangemaakt ({len(env_overrides)} waarden)") # Image tags aanpassen in compose if image_tags: cf = docker.find_compose(dest) if cf: content = cf.read_text() for svc, tag in image_tags.items(): if not tag: continue # Vervang image:xxx tag # Simple approach: replace all image lines new_content = content for m in re.finditer(r'image:\s*["\']?([^\s"\']+)', content): img = m.group(1) repo_part = img.split(":")[0] if ":" in img else img # Only replace if no variable reference if "${" not in img: new_content = new_content.replace(img, f"{repo_part}:{tag}", 1) jobs.log(qq, "dim", f" {repo_part}:{tag}") break # Only first service for now cf.write_text(new_content) jobs.log(qq, "ok", "Image tags aangepast") # Data dir try: Path(cfg.load()["DATA_DIR"]).resolve().mkdir(parents=True, exist_ok=True) except Exception: pass # Start jobs.log(qq, "section", "Starten") rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst) jobs.log(qq, "ok" if rc == 0 else "warn", "Gestart" if rc == 0 else "Start mislukt") audit.log("store", "install", "ok", ref=inst, detail={"env": list(env_overrides.keys()), "tags": image_tags}) jobs.finish(jid, "done") except PermissionError as e: jobs.log(qq, "error", f"Geen schrijfrechten: {e}") jobs.finish(jid, "error") except Exception as e: jobs.log(qq, "error", f"{type(e).__name__}: {e}") jobs.finish(jid, "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) # ── Module Store ────────────────────────────────────────────────────────────── @app.route("/api/modules") def api_modules(): active = cfg.load().get("ACTIVE_MODULES") out = [] for m in MODULES: en = active is None or m.ID in (active or []) info = {**m.info(), "enabled": en} js = _mod_js(m) if js: info["js"] = f"/api/modules/{m.ID}/js" out.append(info) return jsonify(out) @app.route("/api/modules//toggle", methods=["POST"]) def api_mod_toggle(mid): en = (request.json or {}).get("enabled", False) c = cfg.load() active = list(c.get("ACTIVE_MODULES") or [m.ID for m in MODULES]) if en and mid not in active: active.append(mid) elif not en and mid in active: active.remove(mid) cfg.patch({"ACTIVE_MODULES": active}) return jsonify(ok=True) @app.route("/api/modules//js") def api_mod_js(mid): for m in MODULES: if m.ID == mid: js = _mod_js(m) if js: return Response(js.read_text(), mimetype="application/javascript") return Response("", 404, mimetype="application/javascript") @app.route("/api/modules/store") def api_mod_store(): c = cfg.load() installed = {} if USER_MOD.exists(): for d in USER_MOD.iterdir(): if d.is_dir() and (d / "__init__.py").exists() and (d / "module.json").exists(): mid = d.name if mid in CORE: continue meta = {} try: meta = json.loads((d / "module.json").read_text()) except Exception: pass installed[mid] = meta.get("version", "0.0.0") available = {} for repo in c.get("MODULE_REPOS", []): for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")): if mod["id"] not in CORE and mod["id"] not in available: available[mod["id"]] = mod result = [] for mid, mod in available.items(): is_installed = mid in installed mod["installed"] = is_installed if is_installed: local_ver = installed[mid] repo_ver = mod.get("version", "0.0.0") mod["installed_version"] = local_ver mod["repo_version"] = repo_ver if _ver_newer(repo_ver, local_ver): mod["status"] = "update" else: mod["status"] = "installed" else: mod["status"] = "available" result.append(mod) for mid, ver in installed.items(): if mid not in available and mid not in CORE: result.append({"id": mid, "name": mid, "icon": "🧩", "version": ver, "installed_version": ver, "desc": "", "installed": True, "status": "orphan"}) return jsonify(modules=sorted(result, key=lambda m: m.get("name", ""))) def _ver_newer(repo_ver: str, local_ver: str) -> bool: """Vergelijk semver strings: True als repo_ver nieuwer is dan local_ver.""" def _parse(v): try: return tuple(int(x) for x in v.replace("v", "").split(".")[:3]) except (ValueError, AttributeError): return (0, 0, 0) return _parse(repo_ver) > _parse(local_ver) @app.route("/api/modules/install", methods=["POST"]) def api_mod_install(): mid = (request.json or {}).get("id", "").strip() if not mid or mid in CORE: return jsonify(ok=False, msg="niet mogelijk") src = None for repo in cfg.load().get("MODULE_REPOS", []): for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")): if mod["id"] == mid: src = Path(mod["_src"]) break if src: break if not src: return jsonify(ok=False, msg="niet gevonden in repo") # Valideer: heeft module.json? if not (src / "module.json").exists(): return jsonify(ok=False, msg=f"'{mid}' heeft geen module.json — geen geldige module") # Valideer: heeft Module subclass? from core.modules import _load_detail cls, load_error = _load_detail(mid, src) if not cls: # Toon de __init__.py inhoud voor debug init_content = "" try: init_content = (src / "__init__.py").read_text()[:500] except Exception: init_content = "(kon niet lezen)" return jsonify(ok=False, msg=f"'{mid}' kon niet geladen worden: {load_error}", debug={"src": str(src), "init_preview": init_content, "files": [f.name for f in src.iterdir()][:20]}) dest = USER_MOD / mid dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): shutil.rmtree(dest) shutil.copytree(str(src), str(dest)) active = cfg.load().get("ACTIVE_MODULES") if active is not None: active = list(active) if mid not in active: active.append(mid) cfg.patch({"ACTIVE_MODULES": active}) audit.log("modules", "install", "ok", ref=mid) ok, msg = _notify_restart(mid) return jsonify(ok=True, hot=ok, msg=msg) @app.route("/api/modules/uninstall", methods=["POST"]) def api_mod_uninstall(): mid = (request.json or {}).get("id", "").strip() if mid in CORE: return jsonify(ok=False, msg="core kan niet verwijderd worden") dest = USER_MOD / mid if not dest.exists(): return jsonify(ok=False, msg="niet gevonden") shutil.rmtree(dest) active = cfg.load().get("ACTIVE_MODULES") if active is not None: cfg.patch({"ACTIVE_MODULES": [m for m in active if m != mid]}) audit.log("modules", "uninstall", "ok", ref=mid) return jsonify(ok=True, msg="verwijderd — herstart vereist") @app.route("/api/modules/update", methods=["POST"]) def api_mod_update(): """Update een module naar de nieuwste versie uit de repo. Behoudt settings.""" mid = (request.json or {}).get("id", "").strip() if not mid or mid in CORE: return jsonify(ok=False, msg="niet mogelijk") # Zoek nieuwste versie in repo src = None for repo in cfg.load().get("MODULE_REPOS", []): for mod in git.scan_modules(repo["id"], subdir=repo.get("subdir", "")): if mod["id"] == mid: src = Path(mod["_src"]) break if src: break if not src: return jsonify(ok=False, msg="niet gevonden in repo") # Valideer from core.modules import _load_detail cls, err = _load_detail(mid, src) if not cls: return jsonify(ok=False, msg=f"update fout: {err}") # Lees oude + nieuwe versie dest = USER_MOD / mid old_ver = "?" if dest.exists(): try: old_meta = json.loads((dest / "module.json").read_text()) old_ver = old_meta.get("version", "?") except Exception: pass new_ver = "?" try: new_meta = json.loads((src / "module.json").read_text()) new_ver = new_meta.get("version", "?") except Exception: pass # Overschrijf bestanden (settings in MODULE_SETTINGS blijven bewaard) dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): shutil.rmtree(dest) shutil.copytree(str(src), str(dest)) audit.log("modules", "update", "ok", ref=f"{mid} {old_ver}→{new_ver}") return jsonify(ok=True, msg=f"Bijgewerkt {old_ver} → {new_ver} — herstart vereist", old_version=old_ver, new_version=new_ver) # ── Module repos ────────────────────────────────────────────────────────────── @app.route("/api/modrepos") def api_modrepos(): repos = cfg.load().get("MODULE_REPOS", []) return jsonify([{**r, "cloned": git.cache_dir(r["id"]).exists()} for r in repos]) @app.route("/api/modrepos", methods=["POST"]) def api_modrepos_add(): d = request.json or {} url = d.get("url", "").strip() if not url: return jsonify(ok=False, msg="URL vereist") repos = list(cfg.load().get("MODULE_REPOS", [])) repos.append({"id": d.get("id", url.split("/")[-1].replace(".git", ""))[:30], "name": d.get("name", "") or url.split("/")[-1], "url": url, "branch": d.get("branch", "main"), "subdir": d.get("subdir", "")}) cfg.patch({"MODULE_REPOS": repos}) return jsonify(ok=True) @app.route("/api/modrepos/", methods=["DELETE"]) def api_modrepos_del(rid): repos = [r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] != rid] cfg.patch({"MODULE_REPOS": repos}) c = git.cache_dir(rid) if c.exists(): shutil.rmtree(c) return jsonify(ok=True) @app.route("/api/modrepos//sync", methods=["GET", "POST"]) def api_modrepos_sync(rid): repo = next((r for r in cfg.load().get("MODULE_REPOS", []) if r["id"] == rid), None) if not repo: return jsonify(ok=False, msg="niet gevonden") jid, q = jobs.create(f"modsync:{rid}") def _run(jid): qq = jobs.get_queue(jid) ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if ok else "error", msg) jobs.finish(jid, "done" if ok else "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) # ── Settings ────────────────────────────────────────────────────────────────── @app.route("/api/settings") def api_settings(): c = cfg.load() hide = {"WIZARD_DONE", "ACTIVE_MODULES"} return jsonify({k: v for k, v in c.items() if k not in hide}) @app.route("/api/settings", methods=["PUT"]) def api_settings_put(): d = request.json or {} hide = {"WIZARD_DONE", "ACTIVE_MODULES"} updates = {k: v for k, v in d.items() if k not in hide} if updates: cfg.patch(updates) return jsonify(ok=True) @app.route("/api/settings/panels") def api_settings_panels(): panels = [] for m in MODULES: h = m.settings_html() mc = cfg.load().get("MODULE_SETTINGS", {}).get(m.ID, {}) # Lees settings schema uit module.json schema = {} src = getattr(m, "_src", None) if src: mf = Path(src) / "module.json" if mf.exists(): try: meta = json.loads(mf.read_text()) schema = meta.get("settings", {}) except Exception: pass panels.append({ "id": m.ID, "name": m.NAME or m.ID, "icon": m.ICON, "has_html": bool(h and h.strip()), "html": h or "", "config": mc, "schema": schema, }) return jsonify(panels=panels) @app.route("/api/modules//settings") def api_mod_settings_get(mid): mc = cfg.load().get("MODULE_SETTINGS", {}).get(mid, {}) return jsonify(config=mc) @app.route("/api/modules//settings", methods=["PUT", "POST"]) def api_mod_settings_put(mid): d = request.json or {} c = cfg.load() ms = dict(c.get("MODULE_SETTINGS", {})) ms[mid] = d cfg.patch({"MODULE_SETTINGS": ms}) audit.log("modules", "settings", "ok", ref=mid) return jsonify(ok=True) # ── Audit ───────────────────────────────────────────────────────────────────── @app.route("/api/audit") def api_audit(): return jsonify(entries=audit.query(200), total=audit.count()) @app.route("/api/audit/clear", methods=["POST"]) def api_audit_clear(): audit.clear() audit.log("audit", "clear", "ok") return jsonify(ok=True) # ── i18n ────────────────────────────────────────────────────────────────────── @app.route("/api/i18n") def api_i18n(): return jsonify(languages=i18n.available()) @app.route("/api/i18n/") def api_i18n_lang(lang): return jsonify(strings=i18n.strings(lang)) # ── Wizard ──────────────────────────────────────────────────────────────────── @app.route("/api/wizard") def api_wizard(): c = cfg.load() return jsonify(done=bool(c.get("WIZARD_DONE")), defaults={k: c.get(k) for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR", "APP_REPOS", "MODULE_REPOS")}) @app.route("/api/wizard/paths", methods=["GET", "POST"]) def api_wizard_paths(): if request.method == "GET": return jsonify(ok=True, msg="use POST") try: d = request.json or {} updates = {} errors = [] for k in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"): if k in d and d[k]: updates[k] = d[k] try: Path(d[k]).mkdir(parents=True, exist_ok=True) except Exception as e: errors.append(f"{d[k]}: {e}") if updates: cfg.patch(updates) return jsonify(ok=True, errors=errors) except Exception as e: return jsonify(ok=False, msg=str(e)) @app.route("/api/wizard/repos", methods=["GET", "POST"]) def api_wizard_repos(): """Sla repos op vanuit de wizard.""" if request.method == "GET": return jsonify(ok=True, msg="use POST") try: d = request.json or {} updates = {} app_repos = d.get("APP_REPOS") if app_repos is not None: updates["APP_REPOS"] = [r for r in app_repos if r.get("url", "").strip()] mod_repos = d.get("MODULE_REPOS") if mod_repos is not None: updates["MODULE_REPOS"] = [r for r in mod_repos if r.get("url", "").strip()] if updates: cfg.patch(updates) audit.log("wizard", "repos", "ok", detail={"app": len(updates.get("APP_REPOS", [])), "mod": len(updates.get("MODULE_REPOS", []))}) return jsonify(ok=True) except Exception as e: return jsonify(ok=False, msg=str(e)) @app.route("/api/wizard/sync", methods=["GET", "POST"]) def api_wizard_sync(): jid, q = jobs.create("wizard:sync") def _run(jid): qq = jobs.get_queue(jid) c = cfg.load() ok_all = True for label, repos in [("App", c.get("APP_REPOS", [])), ("Module", c.get("MODULE_REPOS", []))]: for repo in repos: if not repo.get("url"): continue jobs.log(qq, "section", f"{label}: {repo.get('name', repo['id'])}") ok, msg = git.clone_or_pull(repo, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if ok else "error", msg) if not ok: ok_all = False jobs.finish(jid, "done" if ok_all else "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) @app.route("/api/wizard/git", methods=["POST"]) def api_wizard_git(): """Verwerk de eigen-git stap van de wizard.""" d = request.json or {} action = d.get("action", "skip") if action == "add": url = d.get("url", "").strip() if not url: return jsonify(ok=False, msg="URL vereist") slug = url.rstrip("/").split("/")[-1].removesuffix(".git") or "eigen-git" repo = {"id": slug, "url": url, "branch": "main"} if d.get("token"): repo["token"] = d["token"].strip() if d.get("subdir"): repo["subdir"] = d["subdir"].strip() c = cfg.load() repos = c.get("APP_REPOS", []) if not any(r.get("url") == url for r in repos): repos.append(repo) cfg.patch({"APP_REPOS": repos}) audit.log("wizard", "git_add", "ok", detail={"url": url}) return jsonify(ok=True) if action == "install_forgejo": su_repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == "server-up"), None) if not su_repo: return jsonify(ok=False, msg="server-up repo niet geconfigureerd") cache = git.cache_dir("server-up") subdir = su_repo.get("subdir", "apps") src = (cache / subdir / "forgejo").resolve() if subdir else (cache / "forgejo").resolve() if not src.is_dir(): ok_sync, _ = git.clone_or_pull(su_repo) if not ok_sync or not src.is_dir(): return jsonify(ok=False, msg="Forgejo boilerplate niet gevonden — sync mislukt") lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() dest = (lib / "forgejo").resolve() if dest.exists(): return jsonify(ok=False, msg="'forgejo' bestaat al in de library") try: lib.mkdir(parents=True, exist_ok=True) except Exception as e: return jsonify(ok=False, msg=str(e)) port_web = int(d.get("port_web") or 3000) port_ssh = int(d.get("port_ssh") or 2222) data_dir = d.get("data_dir", cfg.load().get("DATA_DIR", "/opt/serverup/appdata")) values = { "service_name": "forgejo", "data_dir": data_dir, "timezone": "Europe/Amsterdam", "port_web": port_web, "port_ssh": port_ssh, } jid, q = jobs.create("wizard:install-forgejo") def _run(jid): qq = jobs.get_queue(jid) try: jobs.log(qq, "section", "Forgejo installeren") boilerplates.render_to_dir(src, dest, values) jobs.log(qq, "ok", f"Gerenderd naar {dest}") ok, msg = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if ok else "error", msg) audit.log("wizard", "install_forgejo", "ok" if ok else "error", ref="forgejo") jobs.finish(jid, "done" if ok else "error") except Exception as e: jobs.log(qq, "error", str(e)) jobs.finish(jid, "error") jobs.done(qq) jobs.run(_run, jid) return jsonify(ok=True, job_id=jid) return jsonify(ok=True) @app.route("/api/wizard/complete", methods=["GET", "POST"]) def api_wizard_done(): cfg.patch({"WIZARD_DONE": True}) audit.log("wizard", "complete", "ok") return jsonify(ok=True) @app.route("/api/wizard/reset", methods=["GET", "POST"]) def api_wizard_reset(): c = cfg.load() c.pop("WIZARD_DONE", None) cfg.save(c) return jsonify(ok=True) # ── Jobs ────────────────────────────────────────────────────────────────────── @app.route("/api/debug/cache") @app.route("/api/debug/cache/") def api_debug_cache(rid=None): """Debug: toon cache structuur.""" if not os.environ.get("SU_DEBUG"): return jsonify(ok=False, msg="niet beschikbaar"), 404 if rid is None: # Toon alle caches cache_root = git.CACHE if not cache_root.exists(): return jsonify(exists=False, path=str(cache_root)) repos = [] for d in sorted(cache_root.iterdir()): if d.is_dir(): repos.append({"id": d.name, "path": str(d), "contents": [f.name for f in sorted(d.iterdir()) if f.is_dir() and f.name != ".git"][:20]}) return jsonify(cache_path=str(cache_root), repos=repos) cache = git.cache_dir(rid) if not cache.exists(): return jsonify(exists=False, path=str(cache)) dirs = [] for p in sorted(cache.rglob("*")): if p.is_dir() and ".git" not in str(p): rel = str(p.relative_to(cache)) has_comp = docker.has_compose(p) files = [f.name for f in p.iterdir() if f.is_file()][:10] dirs.append({"path": rel, "has_compose": has_comp, "files": files}) return jsonify(exists=True, path=str(cache), dirs=dirs[:100]) @app.route("/api/debug/config") def api_debug_config(): """Debug: toon volledige config.""" if not os.environ.get("SU_DEBUG"): return jsonify(ok=False, msg="niet beschikbaar"), 404 return jsonify(cfg.load()) @app.route("/api/debug/modules") def api_debug_modules(): """Debug: probeer alle modules uit repo te laden en toon resultaat.""" if not os.environ.get("SU_DEBUG"): return jsonify(ok=False, msg="niet beschikbaar"), 404 from core.modules import _load_detail results = [] c = cfg.load() for repo in c.get("MODULE_REPOS", []): cache = git.cache_dir(repo["id"]) subdir = repo.get("subdir", "") base = cache / subdir if subdir else cache if not base.exists(): results.append({"repo": repo["id"], "error": f"pad bestaat niet: {base}"}) continue for d in sorted(base.iterdir()): if not d.is_dir() or d.name.startswith((".", "_")): continue has_json = (d / "module.json").exists() has_init = (d / "__init__.py").exists() init_preview = "" if has_init: try: init_preview = (d / "__init__.py").read_text()[:300] except Exception: pass cls, error = _load_detail(d.name, d) if has_init else (None, "geen __init__.py") results.append({ "dir": d.name, "path": str(d), "has_module_json": has_json, "has_init": has_init, "init_preview": init_preview, "load_ok": cls is not None, "load_error": error, "class_name": cls.__name__ if cls else None, }) return jsonify(results=results) @app.route("/api/debug/startup") def api_debug_startup(): """Debug: toon startup module loading log.""" if not os.environ.get("SU_DEBUG"): return jsonify(ok=False, msg="niet beschikbaar"), 404 return jsonify(log=STARTUP_LOG, loaded=[m.ID for m in MODULES]) @app.route("/api/jobs/") def api_job(jid): off = int(request.args.get("offset", 0)) return jsonify(jobs.stream(jid, off)) # ── Boot helpers ───────────────────────────────────────────────────────────── def _apply_boot_repos(): """Sla SU_BOOT_REPOS repos op in config.json als ze er nog niet in staan. Zorgt dat repos uit de env-var ook na een herstart beschikbaar blijven, ook als SU_BOOT_REPOS later verwijderd wordt. """ raw = os.environ.get("SU_BOOT_REPOS", "").strip() if not raw: return try: extra = json.loads(raw) except Exception as e: print(f" [boot] SU_BOOT_REPOS parse fout: {e}") return c = cfg.load() existing_ids = {r.get("id") for r in c.get("APP_REPOS", [])} new_repos = [r for r in extra if isinstance(r, dict) and r.get("id") and r["id"] not in existing_ids] if new_repos: cfg.patch({"APP_REPOS": list(c.get("APP_REPOS", [])) + new_repos}) for r in new_repos: print(f" [boot] repo opgeslagen: {r.get('id')} ({r.get('url', '')})") audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url")) def _boot_sync(): """Sync alle geconfigureerde repos bij elke start (achtergrond-thread). Loopt na een korte pauze zodat Flask volledig opgestart is. Bij eerste start worden repos gecloned; bij herstart wordt git pull gedraaid. """ import time time.sleep(2) c = cfg.load() for label, repo_list in [("App", c.get("APP_REPOS", [])), ("Module", c.get("MODULE_REPOS", []))]: for repo in repo_list: if not repo.get("url"): continue rid = repo.get("id", "?") try: ok, msg = git.clone_or_pull(repo, log_fn=None) status = "ok" if ok else "error" print(f" [boot] sync {label} {rid}: {status}" + (f" — {msg[:80]}" if not ok else "")) audit.log("core", "boot_sync", status, ref=rid, detail=msg[:100] if not ok else "") except Exception as e: print(f" [boot] sync {label} {rid}: fout — {e}") audit.log("core", "boot_sync", "error", ref=rid, detail=str(e)) # ══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": port = int(os.environ.get("PORT", 5000)) print(f"\n🚀 Server Up · http://0.0.0.0:{port}\n") audit.init() i18n.load() print("Boot repos:") _apply_boot_repos() print("Modules:") load_modules() if not MODULES: print(" (geen)") audit.log("core", "startup", "ok") print("Boot sync (achtergrond):") jobs.run(_boot_sync) print(f"\n→ http://localhost:{port}\n") app.run(host="0.0.0.0", port=port, debug=False, threaded=True)