Files
Enclume/enclume/webapp.py
T
hcornet b063d59525
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
2026-09-10 16:28:26 +02:00

230 lines
8.3 KiB
Python

"""Routes de l'application.
Le serveur est volontairement sans etat a ce stade : il valide, il genere, il
ne conserve rien. Le projet vit dans le navigateur. Le partage, les projets
enregistres et la connexion viendront s'ajouter par-dessus sans changer ce
noyau.
"""
from __future__ import annotations
import io
import json
import os
import zipfile
from flask import Flask, Response, jsonify, render_template, request
from . import auth
from .catalog import CATEGORIES, Catalog
from .config import Config
from .db import db
from .generator import build_plan, generate, render_bundle
from .model import CENTREON_VERSIONS, COMMAND_TYPES, Project, detect_macros, strip_secrets
from .packs import pack_to_project
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG_DIR = os.environ.get("ENCLUME_CATALOG", os.path.join(BASE_DIR, "catalog"))
CATALOG_FALLBACK = os.environ.get("ENCLUME_CATALOG_FALLBACK", os.path.join(BASE_DIR, "catalog"))
MAX_BODY = 2 * 1024 * 1024 # un projet honnete pese quelques dizaines de ko
def _repertoire_catalogue() -> str:
"""Repertoire a lire : celui synchronise, sinon celui livre dans l'image.
Le volume partage est vide tant que catalog-sync n'a rien publie, et il peut
le rester si Git est injoignable. Servir un site sans aucun pack serait pire
que servir un catalogue un peu ancien : on se rabat sur celui de l'image.
"""
for chemin in (CATALOG_DIR, CATALOG_FALLBACK):
if not chemin or not os.path.isdir(chemin):
continue
for _racine, _dossiers, fichiers in os.walk(chemin):
if any(f.endswith((".yml", ".yaml")) for f in fichiers):
return chemin
return CATALOG_FALLBACK
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object(Config)
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY
app.config["CATALOG"] = Catalog(_repertoire_catalogue())
if Config.base_active():
db.init_app(app)
auth.init_app(app)
from .routes_donnees import bp as bp_donnees
app.register_blueprint(bp_donnees)
if not Config.SECRET_KEY_FOURNIE and Config.base_active():
app.logger.warning(
"ENCLUME_SECRET_KEY absente : les sessions seront perdues au redemarrage."
)
def catalog() -> Catalog:
return app.config["CATALOG"]
def project_from_request() -> Project:
payload = request.get_json(force=True, silent=True) or {}
return Project.from_dict(payload.get("project", payload))
@app.after_request
def security_headers(response: Response) -> Response:
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
return response
# ----------------------------------------------------------------- pages
@app.context_processor
def contexte_commun() -> dict:
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
return {
"version": Config.VERSION,
"fonctions": Config.etat(),
"utilisateur": utilisateur,
"oidc_nom": Config.OIDC_NOM,
}
@app.get("/")
def editor() -> str:
cat = catalog()
return render_template(
"editeur.html",
versions=CENTREON_VERSIONS,
command_types=COMMAND_TYPES,
categories=CATEGORIES,
grouped=cat.by_category(),
pack_count=len(cat.packs),
service_count=cat.service_count,
)
@app.get("/p/<identifiant>")
def page_partage(identifiant: str) -> str:
"""Ouvre l'editeur, qui chargera le projet partage depuis l'API."""
cat = catalog()
return render_template(
"editeur.html",
versions=CENTREON_VERSIONS,
command_types=COMMAND_TYPES,
categories=CATEGORIES,
grouped=cat.by_category(),
pack_count=len(cat.packs),
service_count=cat.service_count,
partage=identifiant,
)
@app.get("/moderation")
def page_moderation():
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
if utilisateur is None:
return render_template("moderation.html", autorise=False, connecte=False)
return render_template(
"moderation.html", autorise=utilisateur.administrateur, connecte=True
)
@app.get("/sante")
def health() -> Response:
cat = catalog()
return jsonify(
{
"statut": "ok",
"version": os.environ.get("ENCLUME_VERSION", "dev"),
"revision": os.environ.get("ENCLUME_COMMIT", "inconnu"),
"packs": len(cat.packs),
"source_catalogue": cat.root,
"erreurs_catalogue": cat.errors,
"fonctions": Config.etat(),
}
)
# ------------------------------------------------------------------- api
@app.get("/api/packs")
def api_packs() -> Response:
return jsonify(
[
{
"id": p.id,
"name": p.name,
"category": p.category,
"category_label": p.category_label,
"status": p.status,
"plugin": p.plugin,
"description": p.description,
"host_template": p.host_template.name,
"services": [
{"name": s.name, "description": s.description, "default": s.enabled_by_default}
for s in p.services
],
}
for p in sorted(catalog().packs.values(), key=lambda p: (p.category, p.name))
]
)
@app.get("/api/packs/<pack_id>/projet")
def api_pack_project(pack_id: str) -> Response:
pack = catalog().get(pack_id)
if pack is None:
return jsonify({"erreur": "pack inconnu"}), 404
only_defaults = request.args.get("tout") != "1"
return jsonify(pack_to_project(pack, only_defaults=only_defaults).to_dict())
@app.post("/api/valider")
def api_validate() -> Response:
plan = build_plan(project_from_request())
return jsonify({"summary": plan.summary, "issues": plan.issues, "has_errors": plan.has_errors})
@app.post("/api/generer")
def api_generate() -> Response:
payload = request.get_json(force=True, silent=True) or {}
return jsonify(generate(payload.get("project", payload)))
@app.post("/api/macros")
def api_macros() -> Response:
payload = request.get_json(force=True, silent=True) or {}
return jsonify(detect_macros(str(payload.get("line", ""))))
@app.post("/telecharger")
def download_zip() -> Response:
raw = request.form.get("project") or "{}"
project = Project.from_dict(json.loads(raw))
plan = build_plan(project)
files = render_bundle(plan)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name, content in files.items():
archive.writestr(name, content)
# Le projet joint a l'archive ne contient aucun mot de passe.
archive.writestr(
"projet.json",
json.dumps(
strip_secrets(Project.from_dict(json.loads(raw))).to_dict(),
indent=2,
ensure_ascii=False,
),
)
buffer.seek(0)
slug = "".join(c if c.isalnum() or c in "-_" else "-" for c in project.name).strip("-") or "projet"
return Response(
buffer.read(),
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="{slug}-clapi.zip"'},
)
@app.post("/recharger")
def reload_catalog() -> Response:
app.config["CATALOG"] = Catalog(_repertoire_catalogue())
cat = catalog()
return jsonify({"packs": len(cat.packs), "source": cat.root, "erreurs": cat.errors})
return app