CI/CD hardening: lint, secret scan, coverage gate, auto CVE-fix PRs, GHCR + GitHub mirror publishing (#34)
CI / build-and-scan (push) Successful in 2m43s

- release changelog: commits rendered as description (link), divider lines dropped
- gitleaks secret scan and hadolint on every push/PR
- ruff lint/format gate (Python repos) with a pytest --cov-fail-under gate
- scheduled CRITICAL Trivy failures attempt an apk upgrade rebuild and open a follow-up PR if it clears the finding, instead of just failing red
- images also published to ghcr.io/djeex/<repo>
- a matching GitHub Release is created on the GitHub mirror, with a notice pointing back to this repo as the source of truth
This commit was merged in pull request #34.
This commit is contained in:
2026-08-26 15:38:54 +02:00
parent edd39febd1
commit f22634e9d9
18 changed files with 354 additions and 111 deletions
+111 -41
View File
@@ -1,16 +1,27 @@
# --- Imports ---
import logging
import yaml
import os
import subprocess
import zipfile
import os
from pathlib import Path
import yaml
from flask import (
Flask, jsonify, request, send_from_directory, render_template,
send_file, after_this_request
Flask,
after_this_request,
jsonify,
render_template,
request,
send_file,
send_from_directory,
)
from src.py.builder.gallery_builder import (
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
GALLERY_YAML,
load_yaml,
save_yaml,
update_gallery,
update_hero,
)
from src.py.webui.upload import upload_bp
@@ -19,16 +30,11 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
# --- Flask app setup ---
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
with open(VERSION_FILE, "r") as vf:
with open(VERSION_FILE) as vf:
lumeex_version = vf.read().strip()
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
app = Flask(
__name__,
template_folder=WEBUI_PATH,
static_folder=WEBUI_PATH,
static_url_path=""
)
app = Flask(__name__, template_folder=WEBUI_PATH, static_folder=WEBUI_PATH, static_url_path="")
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
@@ -40,26 +46,34 @@ app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint ---
app.register_blueprint(upload_bp)
# --- Theme editor helper functions ---
def get_theme_name():
"""Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f:
with open(site_yaml_path) as f:
site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml_path = (
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path) as f:
return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
theme_yaml_path = (
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name):
"""List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
@@ -67,21 +81,23 @@ def get_local_fonts(theme_name):
return []
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- ROUTES ---
# --- Main page ---
@app.route("/")
def index():
return render_template("index.html")
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
@app.context_processor
def inject_version():
return dict(
lumeex_version=lumeex_version,
preview_port=PREVIEW_PORT
)
return dict(lumeex_version=lumeex_version, preview_port=PREVIEW_PORT)
# --- Gallery & Hero API ---
@app.route("/gallery-editor")
@@ -89,18 +105,21 @@ def gallery_editor():
"""Render gallery editor page."""
return render_template("gallery-editor/index.html")
@app.route("/api/gallery", methods=["GET"])
def get_gallery():
"""Get gallery images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"])
def get_hero():
"""Get hero images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("hero", {}).get("images", []))
@app.route("/api/gallery/update", methods=["POST"])
def update_gallery_api():
"""Update gallery images."""
@@ -110,6 +129,7 @@ def update_gallery_api():
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"})
@app.route("/api/hero/update", methods=["POST"])
def update_hero_api():
"""Update hero images."""
@@ -119,18 +139,21 @@ def update_hero_api():
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"})
@app.route("/api/gallery/refresh", methods=["POST"])
def refresh_gallery():
"""Refresh gallery images from disk."""
update_gallery()
return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero():
"""Refresh hero images from disk."""
update_hero()
return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo():
@@ -143,6 +166,7 @@ def delete_gallery_photo():
return {"status": "ok"}
return {"error": "❌ File not found"}, 404
@app.route("/api/hero/delete", methods=["POST"])
def delete_hero_photo():
"""Delete a hero photo."""
@@ -154,6 +178,7 @@ def delete_hero_photo():
return {"status": "ok"}
return {"error": "❌ File not found"}, 404
@app.route("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos():
"""Delete all gallery photos."""
@@ -168,6 +193,7 @@ def delete_all_gallery_photos():
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
@app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos():
"""Delete all hero photos."""
@@ -182,36 +208,41 @@ def delete_all_hero_photos():
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos ---
@app.route("/photos/<section>/<path:filename>")
def photos(section, filename):
"""Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>")
def serve_photo(filename):
"""Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename)
# --- Site info page & API ---
@app.route("/site-info")
def site_info():
"""Render site info editor page."""
return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"])
def get_site_info():
"""Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f:
with open(SITE_YAML) as f:
data = yaml.safe_load(f)
return jsonify(data)
@app.route("/api/site-info", methods=["POST"])
def update_site_info():
"""Update site info YAML."""
new_data = request.json
with open(SITE_YAML, "r") as f:
with open(SITE_YAML) as f:
old_data = yaml.safe_load(f) or {}
def deep_merge(old, new):
@@ -227,6 +258,7 @@ def update_site_info():
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes")
def list_themes():
@@ -235,6 +267,7 @@ def list_themes():
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes)
# --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail():
@@ -245,13 +278,14 @@ def upload_thumbnail():
return {"error": "❌ No file provided"}, 400
filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename)
with open(SITE_YAML, "r") as f:
with open(SITE_YAML) as f:
data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail():
"""Remove thumbnail image and update site.yaml."""
@@ -259,7 +293,7 @@ def remove_thumbnail():
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
if thumbnail_path.exists():
thumbnail_path.unlink()
with open(SITE_YAML, "r") as f:
with open(SITE_YAML) as f:
data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]:
data["social"]["thumbnail"] = ""
@@ -267,6 +301,7 @@ def remove_thumbnail():
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"])
def upload_theme():
@@ -286,6 +321,7 @@ def upload_theme():
file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name})
@app.route("/api/theme/remove", methods=["POST"])
def remove_theme():
"""Remove a custom theme folder."""
@@ -302,15 +338,18 @@ def remove_theme():
return jsonify({"error": "❌ Cannot remove default theme"}), 400
# Remove folder and all contents
import shutil
shutil.rmtree(theme_folder)
return jsonify({"status": "ok"})
# --- Theme editor page & API ---
@app.route("/theme-editor")
def theme_editor():
"""Render theme editor page."""
return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info():
"""Get or update theme.yaml for current theme."""
@@ -318,11 +357,9 @@ def api_theme_info():
if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name)
google_fonts = theme_yaml.get("google_fonts", [])
return jsonify({
"theme_name": theme_name,
"theme_yaml": theme_yaml,
"google_fonts": google_fonts
})
return jsonify(
{"theme_name": theme_name, "theme_yaml": theme_yaml, "google_fonts": google_fonts}
)
else:
data = request.get_json()
theme_yaml = data.get("theme_yaml")
@@ -330,20 +367,24 @@ def api_theme_info():
save_theme_yaml(theme_name, theme_yaml)
return jsonify({"status": "ok"})
@app.route("/api/theme-google-fonts", methods=["POST"])
def update_theme_google_fonts():
"""Update only google_fonts in theme.yaml for current theme."""
data = request.get_json()
theme_name = data.get("theme_name")
google_fonts = data.get("google_fonts", [])
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml_path = (
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
)
with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f)
theme_yaml["google_fonts"] = google_fonts
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
@app.route("/api/local-fonts")
def api_local_fonts():
"""List local fonts for a theme."""
@@ -351,6 +392,7 @@ def api_local_fonts():
fonts = get_local_fonts(theme_name)
return jsonify(fonts)
# --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon():
@@ -366,13 +408,14 @@ def upload_favicon():
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename)
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f)
theme_yaml.setdefault("favicon", {})["path"] = filename
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon():
"""Remove favicon for a theme."""
@@ -386,7 +429,7 @@ def remove_favicon():
if favicon_path.exists():
favicon_path.unlink()
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
with open(theme_yaml_path) as f:
theme_yaml = yaml.safe_load(f)
if "favicon" in theme_yaml:
theme_yaml["favicon"]["path"] = ""
@@ -394,6 +437,7 @@ def remove_favicon():
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename):
@@ -401,6 +445,7 @@ def serve_theme_asset(theme, filename):
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename)
# --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"])
def upload_font():
@@ -418,6 +463,7 @@ def upload_font():
font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
"""Remove a font file for a theme."""
@@ -433,6 +479,7 @@ def remove_font():
return jsonify({"status": "ok"})
return jsonify({"error": "❌ Font not found"}), 404
# --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"])
def trigger_build():
@@ -446,7 +493,7 @@ def trigger_build():
if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f:
with open(site_yaml_path) as f:
site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys
@@ -454,24 +501,45 @@ def trigger_build():
for section in main_sections:
value = site_data.get(section)
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}.{k}",
}
), 400
elif isinstance(value, list):
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
for idx, item in enumerate(value):
if isinstance(item, dict):
for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}[{idx}].{k}",
}
), 400
elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
return jsonify(
{
"status": "error",
"message": f"❌ Site info are not set: missing {section}[{idx}]",
}
), 400
else:
if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
return jsonify(
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
), 400
try:
subprocess.run(["python3", "build.py"], check=True)
@@ -479,6 +547,7 @@ def trigger_build():
except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"])
def download_output_zip():
"""
@@ -505,8 +574,9 @@ def download_output_zip():
return send_file(zip_path, as_attachment=True)
# --- Run server ---
if __name__ == "__main__":
logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
app.run(host="0.0.0.0", port=5000, debug=True)
app.run(host="0.0.0.0", port=5000, debug=True)