diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index d9d6558..284b26e 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 \
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', '')}]()
+
"""
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"}