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
230 lines
8.3 KiB
Python
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
|