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
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:
+111
-41
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user