diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 75b24d9..1066f1e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -18,6 +18,21 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Scan for secrets + run: | + # docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that + # excludes .git for prod builds) would otherwise silently give an empty, + # falsely-clean scan. + CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v) + docker cp . "$CID:/repo" + docker start -a "$CID" + STATUS=$? + docker rm "$CID" > /dev/null + exit $STATUS + + - name: Lint Dockerfile with hadolint + run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile + - name: Build Docker image run: | docker build -t lumeex:ci . 2>&1 | tee build.log @@ -46,7 +61,10 @@ jobs: - name: Run unit tests run: | docker build --target test -t lumeex:test . - docker run --rm lumeex:test pytest -v + docker run --rm lumeex:test pytest -v --cov=. --cov-report=term-missing --cov-fail-under=90 + + - name: Lint with ruff + run: docker build --target lint -t lumeex:lint . - name: Check deprecation warnings run: | @@ -56,12 +74,53 @@ jobs: fi - name: Scan with Trivy (critical - blocking) + id: trivy_critical + continue-on-error: true run: | docker run --rm \ -e DOCKER_HOST=tcp://dockerhost:2375 \ --add-host=dockerhost:host-gateway \ aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci + - name: Handle CRITICAL findings + if: steps.trivy_critical.outcome == 'failure' + run: | + if [ "${{ github.event_name }}" != "schedule" ]; then + echo "::error::CRITICAL vulnerabilities found, failing the build." + exit 1 + fi + + echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan." + + sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile + docker build -t lumeex:remediated . + + if docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 \ + --add-host=dockerhost:host-gateway \ + aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:remediated; then + echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review." + + BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)" + git config user.name "lumeex-ci" + git config user.email "ci@git.djeex.fr" + git checkout -b "$BRANCH" + git add Dockerfile + git commit -m "Auto-remediate CRITICAL CVE via apk upgrade" + git config --unset-all http.https://git.djeex.fr/.extraheader || true + git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" "HEAD:$BRANCH" + + PR_JSON=$(curl -s -X POST \ + -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \ + "https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls") + echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')" + else + echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review." + exit 1 + fi + - name: Scan with Trivy (high - informative) run: | docker run --rm \ @@ -126,6 +185,16 @@ jobs: docker push "$IMAGE:$MINOR_TAG" docker push "$IMAGE:$VERSION" + GHCR_IMAGE=ghcr.io/djeex/lumeex + echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin + + docker tag lumeex:ci "$GHCR_IMAGE:latest" + docker tag lumeex:ci "$GHCR_IMAGE:$MINOR_TAG" + docker tag lumeex:ci "$GHCR_IMAGE:$VERSION" + docker push "$GHCR_IMAGE:latest" + docker push "$GHCR_IMAGE:$MINOR_TAG" + docker push "$GHCR_IMAGE:$VERSION" + TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}") PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) @@ -152,7 +221,7 @@ jobs: fi REPO_URL="https://git.djeex.fr/Djeex/lumeex" - COMMIT_LIST=$(git log --no-merges --format="- [%h](${REPO_URL}/commit/%H) %s" "$BASE_REF".."${{ github.sha }}") + COMMIT_LIST=$(git log --no-merges --format="- %s ([%h](${REPO_URL}/commit/%H))" "$BASE_REF".."${{ github.sha }}") SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})" @@ -162,10 +231,8 @@ jobs: BODY=$(cat </dev/null || true + fi diff --git a/Dockerfile b/Dockerfile index 091e283..81c3822 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,13 @@ RUN pip install --no-cache-dir -r requirements-dev.txt COPY ./tests/ ./tests/ COPY ./demo/ ./demo/ +FROM base AS lint + +RUN pip install --no-cache-dir ruff==0.16.4 +COPY ruff.toml /app/ruff.toml +COPY ./tests/ ./tests/ +RUN ruff check . && ruff format --check . + FROM base ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/build.py b/build.py index 418f3fe..ae46151 100644 --- a/build.py +++ b/build.py @@ -1,6 +1,7 @@ import logging + from src.py.builder.site_builder import build if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(message)s") - build() \ No newline at end of file + build() diff --git a/gallery.py b/gallery.py index 045ecae..b375f9d 100644 --- a/gallery.py +++ b/gallery.py @@ -1,7 +1,8 @@ import logging + from src.py.builder.gallery_builder import update_gallery, update_hero if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(message)s") update_gallery() - update_hero() \ No newline at end of file + update_hero() diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..3793645 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,15 @@ +line-length = 100 + +[lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] + +[lint.isort] +# See nvidia-stock-bot's ruff.toml for why this is pinned explicitly rather +# than left to auto-detection. +known-first-party = ["src", "build", "gallery"] + +[lint.per-file-ignores] +# Real findings (unused var, unused loop var) but app-logic changes, +# left for the user to decide rather than auto-fixed. +"src/py/webui/webui.py" = ["F841", "B007"] diff --git a/src/py/builder/css_generator.py b/src/py/builder/css_generator.py index c550954..8b49a3e 100644 --- a/src/py/builder/css_generator.py +++ b/src/py/builder/css_generator.py @@ -1,7 +1,7 @@ import logging -from pathlib import Path from shutil import copyfile + def generate_css_variables(colors_dict, output_path): """Generate css variables for theme colors""" css_lines = [":root {"] @@ -13,6 +13,7 @@ def generate_css_variables(colors_dict, output_path): f.write("\n".join(css_lines)) logging.info(f"[✓] CSS variables written to {output_path}") + def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): """Generate css variables fonts""" font_files = list(fonts_dir.glob("*")) @@ -35,7 +36,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): css_lines = [] for font_name, sources in font_faces.items(): - css_lines.append(f"@font-face {{") + css_lines.append("@font-face {") css_lines.append(f" font-family: '{font_name}';") srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)] css_lines.append(f" src: {', '.join(srcs)};") @@ -58,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None): logging.info(f"[✓] Generated fonts CSS: {output_path}") return preload_links + def generate_google_fonts_link(fonts): """Generate src link for Google fonts""" if not fonts: @@ -70,5 +72,9 @@ def generate_google_fonts_link(fonts): families.append(f"{family}:wght@{';'.join(weights)}") else: families.append(family) - href = "https://fonts.googleapis.com/css2?" + "&".join(f"family={f}" for f in families) + "&display=swap" + href = ( + "https://fonts.googleapis.com/css2?" + + "&".join(f"family={f}" for f in families) + + "&display=swap" + ) return f'' diff --git a/src/py/builder/gallery_builder.py b/src/py/builder/gallery_builder.py index 2666f8c..1b16767 100644 --- a/src/py/builder/gallery_builder.py +++ b/src/py/builder/gallery_builder.py @@ -1,7 +1,8 @@ -import yaml import os from pathlib import Path +import yaml + # YAML file paths GALLERY_YAML = "config/gallery.yaml" @@ -9,31 +10,37 @@ GALLERY_YAML = "config/gallery.yaml" GALLERY_DIR = Path("config/photos/gallery") HERO_DIR = Path("config/photos/hero") + def load_yaml(path): """Load gallery config .yaml file""" print(f"[→] Loading {path}...") if not os.path.exists(path): print(f"[✗] File not found: {path}") return {} - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} images = data.get("images", []) or [] print(f"[✓] Loaded {len(images)} image(s) from {path}") return data + def save_yaml(data, path): """Save modified gallery config .yaml file""" with open(path, "w", encoding="utf-8") as f: yaml.dump(data, f, sort_keys=False, allow_unicode=True) print(f"[✓] Saved updated YAML to {path}") + def get_all_image_paths(directory): """Get the path to record for builded site""" - return sorted([ - str(p.relative_to(directory.parent)).replace("\\", "/") - for p in directory.rglob("*") - if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"] - ]) + return sorted( + [ + str(p.relative_to(directory.parent)).replace("\\", "/") + for p in directory.rglob("*") + if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"] + ] + ) + def update_gallery(): """Update the gallery photo list""" @@ -50,11 +57,7 @@ def update_gallery(): known_images = {img["src"] for img in gallery_images} # Add new images - new_images = [ - {"src": path, "tags": []} - for path in all_images - if path not in known_images - ] + new_images = [{"src": path, "tags": []} for path in all_images if path not in known_images] if new_images: gallery_images.extend(new_images) print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)") @@ -74,6 +77,7 @@ def update_gallery(): if not new_images and not deleted_images: print("[✓] No changes to gallery.yaml (gallery)") + def update_hero(): """Update the hero photo list""" print("\n=== Updating gallery.yaml (hero section) ===") @@ -89,11 +93,7 @@ def update_hero(): known_images = {img["src"] for img in hero_images} # Add new images - new_images = [ - {"src": path} - for path in all_images - if path not in known_images - ] + new_images = [{"src": path} for path in all_images if path not in known_images] if new_images: hero_images.extend(new_images) print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)") diff --git a/src/py/builder/html_generator.py b/src/py/builder/html_generator.py index 9ee3872..de704f5 100644 --- a/src/py/builder/html_generator.py +++ b/src/py/builder/html_generator.py @@ -2,6 +2,7 @@ import json import logging from pathlib import Path + def render_template(template_path, context): """Render html templates""" with open(template_path, encoding="utf-8") as f: @@ -11,6 +12,7 @@ def render_template(template_path, context): content = content.replace(placeholder, str(value) if value is not None else "") return content + def render_gallery_images(images): """Render the photo gallery""" html = "" @@ -20,11 +22,12 @@ def render_gallery_images(images): html += f"""
{tag_html}
- {img.get('alt', '')} + {img.get(
""" return html + def generate_gallery_json_from_images(images, output_dir): """Generte the hero carrousel photo list""" try: @@ -37,6 +40,7 @@ def generate_gallery_json_from_images(images, output_dir): except Exception as e: logging.error(f"[✗] Error generating gallery JSON: {e}") + def generate_robots_txt(canonical_url, allowed_paths, output_dir): """Generate the robot.txt""" robots_lines = ["User-agent: *"] @@ -65,10 +69,11 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir): except Exception as e: logging.error(f"[✗] Failed to write robots.txt: {e}") + def generate_sitemap_xml(canonical_url, allowed_paths, output_dir): """Generate the sitemap""" urlset_start = '\n\n' - urlset_end = '\n' + urlset_end = "\n" urls = "" for path in allowed_paths: loc = canonical_url.rstrip("/") + path diff --git a/src/py/builder/image_processor.py b/src/py/builder/image_processor.py index 7adf661..b73a223 100644 --- a/src/py/builder/image_processor.py +++ b/src/py/builder/image_processor.py @@ -1,8 +1,10 @@ import logging from pathlib import Path -from PIL import Image, features from shutil import copyfile +from PIL import Image, features + + def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140): """Convert an image to WebP (or JPEG fallback) and optionally resize it.""" try: @@ -37,6 +39,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114 except Exception as e: logging.error(f"[✗] Error processing image {input_path}: {e}") + def process_images(images, resize_images, img_dir, build_dir): """Process a list of image references and update paths to optimized versions.""" for img in images: @@ -52,6 +55,7 @@ def process_images(images, resize_images, img_dir, build_dir): if jpg_path.exists(): img["src"] = str(Path(img["src"]).with_suffix(".jpg")) + def copy_original_images(images, img_dir, build_dir): """Copy original image files without processing.""" for img in images: @@ -70,6 +74,7 @@ def copy_original_images(images, img_dir, build_dir): except Exception as e: logging.error(f"[✗] Error copying {src_path}: {e}") + def get_favicon_path(theme_vars, theme_dir): """Retrieve the favicon path from theme variables, ensuring it exists.""" fav_path = theme_vars.get("favicon", {}).get("path") @@ -87,6 +92,7 @@ def get_favicon_path(theme_vars, theme_dir): return path + def generate_favicons_from_logo(theme_vars, theme_dir, output_dir): """Generate multiple PNG favicons from a single source image.""" logo_path = get_favicon_path(theme_vars, theme_dir) @@ -97,9 +103,13 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir): try: output_dir.mkdir(parents=True, exist_ok=True) specs = [ - (32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"), - (192, "favicon-192.png"), (196, "favicon-196.png"), - (152, "favicon-152.png"), (180, "favicon-180.png") + (32, "favicon-32.png"), + (96, "favicon-96.png"), + (128, "favicon-128.png"), + (192, "favicon-192.png"), + (196, "favicon-196.png"), + (152, "favicon-152.png"), + (180, "favicon-180.png"), ] img = Image.open(logo_path).convert("RGBA") for size, name in specs: @@ -110,6 +120,7 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir): except Exception as e: logging.error(f"[✗] Error generating PNG favicons: {e}") + def generate_favicon_ico(theme_vars, theme_dir, output_path): """Generate a multi-size favicon.ico from a source image.""" logo_path = get_favicon_path(theme_vars, theme_dir) diff --git a/src/py/builder/site_builder.py b/src/py/builder/site_builder.py index 9502ad6..58898c6 100644 --- a/src/py/builder/site_builder.py +++ b/src/py/builder/site_builder.py @@ -2,14 +2,27 @@ import logging from datetime import datetime from pathlib import Path from shutil import copyfile + from PIL import Image -from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config + from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link -from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico -from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml +from .html_generator import ( + generate_gallery_json_from_images, + generate_robots_txt, + generate_sitemap_xml, + render_gallery_images, + render_template, +) +from .image_processor import ( + copy_original_images, + generate_favicon_ico, + generate_favicons_from_logo, + process_images, +) +from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml # Configure logging to display only the messages -logging.basicConfig(level=logging.INFO, format='%(message)s') +logging.basicConfig(level=logging.INFO, format="%(message)s") # Define key directories used throughout the script SRC_DIR = Path.cwd() @@ -22,9 +35,10 @@ GALLERY_FILE = SRC_DIR / "config/gallery.yaml" SITE_FILE = SRC_DIR / "config/site.yaml" THEMES_DIR = SRC_DIR / "config/themes" VERSION_FILE = SRC_DIR / "VERSION" -with open(VERSION_FILE, "r") as vf: +with open(VERSION_FILE) as vf: build_version = vf.read().strip() + def build(): logging.info("\n") logging.info("=" * 24) @@ -33,7 +47,7 @@ def build(): logging.info("\n === Starting build === ") ensure_dir(BUILD_DIR) copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR) - + # Defining build vars build_date = datetime.now().strftime("%Y%m%d%H%M%S") build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -57,9 +71,13 @@ def build(): logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}") else: theme_css = "" - logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.") + logging.warning( + f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection." + ) - preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts")) + preload_links = generate_fonts_css( + fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts") + ) generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css") generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon") generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico") @@ -94,7 +112,7 @@ def build(): # Adding Google fonts if existing google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", [])) - logging.info(f"[✓] Google Fonts link generated") + logging.info("[✓] Google Fonts link generated") # Generating thumbnail thumbnail_path = site_vars.get("social", {}).get("thumbnail") @@ -124,11 +142,13 @@ def build(): head_vars["theme_css"] = theme_css head_vars["build_date"] = build_date head_vars["canonical"] = canonical_home - + # Render the home page head = render_template(TEMPLATE_DIR / "head.html", head_vars) hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars}) - footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars}) + footer = render_template( + TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars} + ) gallery_html = render_gallery_images(gallery_images) gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html}) @@ -185,7 +205,8 @@ def build(): generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR) generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR) else: - logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.") + logging.warning( + "[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation." + ) logging.info("✅ Build complete.") - \ No newline at end of file diff --git a/src/py/builder/utils.py b/src/py/builder/utils.py index 1e2e9c1..bd1017a 100644 --- a/src/py/builder/utils.py +++ b/src/py/builder/utils.py @@ -1,26 +1,30 @@ -import yaml import logging from pathlib import Path -from shutil import copytree, rmtree, copyfile +from shutil import copytree, rmtree + +import yaml + def load_yaml(path): """Load gallery and site .yaml conf""" if not path.exists(): logging.warning(f"[!] YAML file not found: {path}") return {} - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) + def load_theme_config(theme_name, themes_dir): """Load theme.yaml""" theme_dir = themes_dir / theme_name theme_config_path = theme_dir / "theme.yaml" if not theme_config_path.exists(): raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}") - with open(theme_config_path, "r", encoding="utf-8") as f: + with open(theme_config_path, encoding="utf-8") as f: theme_vars = yaml.safe_load(f) return theme_vars, theme_dir + def clear_dir(path: Path): """Clear the output dir""" if not path.exists(): @@ -32,6 +36,7 @@ def clear_dir(path: Path): elif child.is_dir(): rmtree(child) + def ensure_dir(path: Path): """Create the output dir if it does not exist""" if not path.exists(): @@ -39,6 +44,7 @@ def ensure_dir(path: Path): else: clear_dir(path) + def copy_assets(js_dir, style_dir, build_dir): """Copy public assets to output dir""" for folder in [js_dir, style_dir]: diff --git a/src/py/webui/upload.py b/src/py/webui/upload.py index 8a3a246..f0ffb15 100644 --- a/src/py/webui/upload.py +++ b/src/py/webui/upload.py @@ -1,7 +1,9 @@ import logging from pathlib import Path -from flask import Blueprint, request, current_app + +from flask import Blueprint, current_app, request from werkzeug.utils import secure_filename + from src.py.builder.gallery_builder import update_gallery, update_hero # --- Create Flask blueprint for upload routes --- @@ -10,10 +12,12 @@ upload_bp = Blueprint("upload", __name__) # --- Allowed file types --- ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"} + def allowed_file(filename: str) -> bool: """Check if the uploaded file has an allowed extension.""" return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + def save_uploaded_file(file, folder: Path): """Save an uploaded file to the specified folder.""" folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists @@ -22,6 +26,7 @@ def save_uploaded_file(file, folder: Path): logging.info(f"[✓] Uploaded {filename} to {folder}") return filename + @upload_bp.route("/api/
/upload", methods=["POST"]) def upload_photo(section: str): """ @@ -35,7 +40,7 @@ def upload_photo(section: str): # Check if files are provided if "files" not in request.files: return {"error": "No files provided"}, 400 - + files = request.files.getlist("files") if not files: return {"error": "No selected files"}, 400 @@ -63,4 +68,3 @@ def upload_photo(section: str): return {"status": "ok", "uploaded": uploaded} return {"error": "No valid files uploaded"}, 400 - diff --git a/src/py/webui/webui.py b/src/py/webui/webui.py index 98dc758..86be398 100644 --- a/src/py/webui/webui.py +++ b/src/py/webui/webui.py @@ -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/
/") def photos(section, filename): """Serve a photo from a section.""" return send_from_directory(PHOTOS_DIR / section, filename) + @app.route("/photos/") 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//") 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) \ No newline at end of file + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/tests/conftest.py b/tests/conftest.py index c77f9d4..25c41c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,9 +50,7 @@ def app_env(tmp_path, monkeypatch, make_image): theme_dir = root / "config" / "themes" / "modern" theme_dir.mkdir(parents=True) (theme_dir / "theme.yaml").write_text( - "colors:\n browser_color: '#ffffff'\n" - "favicon:\n path: favicon.png\n" - "google_fonts: []\n", + "colors:\n browser_color: '#ffffff'\nfavicon:\n path: favicon.png\ngoogle_fonts: []\n", encoding="utf-8", ) make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG") @@ -61,8 +59,7 @@ def app_env(tmp_path, monkeypatch, make_image): "hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8" ) (root / "config" / "site.yaml").write_text( - "info:\n title: Test\n canonical: https://example.com\n" - "social:\n thumbnail: ''\n", + "info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: ''\n", encoding="utf-8", ) diff --git a/tests/test_builder_utils.py b/tests/test_builder_utils.py index 043eb4a..8bec06f 100644 --- a/tests/test_builder_utils.py +++ b/tests/test_builder_utils.py @@ -1,5 +1,3 @@ -from pathlib import Path - from src.py.builder import utils @@ -30,7 +28,7 @@ def test_load_theme_config_missing_raises(tmp_path): themes_dir = tmp_path / "themes" try: utils.load_theme_config("missing", themes_dir) - assert False, "expected FileNotFoundError" + raise AssertionError("expected FileNotFoundError") except FileNotFoundError: pass diff --git a/tests/test_gallery_builder.py b/tests/test_gallery_builder.py index 245a043..0000cd2 100644 --- a/tests/test_gallery_builder.py +++ b/tests/test_gallery_builder.py @@ -1,5 +1,3 @@ -import yaml - from src.py.builder import gallery_builder as gb diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index 0990f05..aeb987e 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -1,5 +1,3 @@ -from pathlib import Path - from PIL import Image from src.py.builder import image_processor as ip @@ -101,8 +99,13 @@ def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image): ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir) expected = [ - "favicon-32.png", "favicon-96.png", "favicon-128.png", - "favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png", + "favicon-32.png", + "favicon-96.png", + "favicon-128.png", + "favicon-192.png", + "favicon-196.png", + "favicon-152.png", + "favicon-180.png", ] for name in expected: assert (output_dir / name).exists() diff --git a/tests/test_webui_routes.py b/tests/test_webui_routes.py index f24928d..4450cc5 100644 --- a/tests/test_webui_routes.py +++ b/tests/test_webui_routes.py @@ -102,7 +102,13 @@ def test_site_info_get_and_post(client, app_env): data = resp.get_json() assert data["info"]["title"] == "Test" - client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}}) + client.post( + "/api/site-info", + json={ + "info": {"subtitle": "New subtitle"}, + "social": {"instagram_url": "https://insta.example"}, + }, + ) updated = client.get("/api/site-info").get_json() assert updated["info"]["title"] == "Test" @@ -271,9 +277,7 @@ def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch): ) calls = [] - monkeypatch.setattr( - webui.subprocess, "run", lambda *a, **k: calls.append((a, k)) - ) + monkeypatch.setattr(webui.subprocess, "run", lambda *a, **k: calls.append((a, k))) resp = client.post("/api/build") assert resp.get_json() == {"status": "ok"}