"""Flask application — recipe importer web UI.""" import hashlib import os import secrets import traceback from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, Response from app import config from app.scraper import scrape, supported_sites from app.mealie import MealieClient from app.tandoor import TandoorClient app = Flask( __name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"), static_folder=os.path.join(os.path.dirname(__file__), "static"), ) app.secret_key = os.environ.get("SECRET_KEY", "recipe-importer-dev-key") VERSION = os.environ.get("VERSION", "dev") # --------------------------------------------------------------------------- # Basic auth middleware # --------------------------------------------------------------------------- _AUTH_EXEMPT = {"/health", "/assets/logo.svg", "/assets/logo_notext.svg"} @app.before_request def _check_basic_auth(): """Enforce HTTP Basic Auth if configured.""" if request.path in _AUTH_EXEMPT or request.path.startswith("/assets/"): return None cfg = config.load() auth_user = cfg.get("auth_username", "").strip() auth_hash = cfg.get("auth_password_hash", "").strip() if not auth_user or not auth_hash: return None # auth not configured — allow all auth = request.authorization if (auth and auth.type == "basic" and auth.username == auth_user and _hash_password(auth.password) == auth_hash): return None # credentials match return Response( "Hitelesítés szükséges.", 401, {"WWW-Authenticate": 'Basic realm="Recept Importáló"'}, ) def _hash_password(password: str) -> str: """SHA-256 hex digest of password.""" return hashlib.sha256(password.encode("utf-8")).hexdigest() _REDIRECT_PATTERNS = ["ide kattintva", "oldalán találod", "teljes recept itt", "kattints ide", "eredeti recept"] def _is_redirect_instructions(instructions: list[str]) -> bool: """Check if instructions are just a redirect to another site.""" if len(instructions) > 2: return False text = " ".join(instructions).lower() return any(p in text for p in _REDIRECT_PATTERNS) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/") def index(): """Redirect to the import page (or settings if not configured).""" cfg = config.load() has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) if not has_mealie and not has_tandoor: return redirect(url_for("settings")) return redirect(url_for("import_page")) @app.route("/settings", methods=["GET", "POST"]) def settings(): """Configure Mealie connection.""" cfg = config.load() if request.method == "POST": cfg["mealie_url"] = request.form.get("mealie_url", "").strip().rstrip("/") cfg["mealie_api_key"] = request.form.get("mealie_api_key", "").strip() cfg["tandoor_url"] = request.form.get("tandoor_url", "").strip().rstrip("/") cfg["tandoor_api_key"] = request.form.get("tandoor_api_key", "").strip() # Auth settings new_user = request.form.get("auth_username", "").strip() new_pass = request.form.get("auth_password", "").strip() if new_user: cfg["auth_username"] = new_user if new_pass: # Only update password hash if a new password was provided cfg["auth_password_hash"] = _hash_password(new_pass) else: # Clear auth if username removed cfg.pop("auth_username", None) cfg.pop("auth_password_hash", None) config.save(cfg) flash("Beállítások mentve.", "success") return redirect(url_for("settings")) return render_template("settings.html", cfg=cfg, version=VERSION) @app.route("/settings/test", methods=["POST"]) def settings_test(): """AJAX endpoint — test Mealie connection using form values.""" url = (request.form.get("mealie_url") or "").strip().rstrip("/") key = (request.form.get("mealie_api_key") or "").strip() if not url or not key: return jsonify({"ok": False, "error": "Nincs megadva Mealie URL vagy API kulcs."}) try: client = MealieClient(url, key, api_url=config.MEALIE_INTERNAL_URL) info = client.test_connection() return jsonify({"ok": True, "data": info}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/settings/test-tandoor", methods=["POST"]) def settings_test_tandoor(): """AJAX endpoint — test Tandoor connection using form values.""" url = (request.form.get("tandoor_url") or "").strip().rstrip("/") key = (request.form.get("tandoor_api_key") or "").strip() if not url or not key: return jsonify({"ok": False, "error": "Nincs megadva Tandoor URL vagy API kulcs."}) try: client = TandoorClient(url, key, api_url=config.TANDOOR_INTERNAL_URL) info = client.test_connection() return jsonify({"ok": True, "data": info}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/import", methods=["GET"]) def import_page(): """Show the import form.""" cfg = config.load() has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) if not has_mealie and not has_tandoor: flash("Először állíts be legalább egy szolgáltatást (Mealie vagy Tandoor).", "warning") return redirect(url_for("settings")) return render_template("import.html", cfg=cfg, version=VERSION, has_mealie=has_mealie, has_tandoor=has_tandoor, supported_sites=supported_sites()) @app.route("/scrape", methods=["POST"]) def scrape_url(): """AJAX — scrape a recipe URL and return structured data.""" url = request.form.get("url", "").strip() if not url: return jsonify({"ok": False, "error": "Nincs URL megadva."}) try: data = scrape(url) # Check for duplicates in Mealie and Tandoor duplicate = None tandoor_duplicate = None cfg = config.load() if cfg.get("mealie_url") and cfg.get("mealie_api_key"): try: client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) duplicate = client.find_duplicate(url, data.get("title", "")) except Exception: pass # non-fatal if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"): try: client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) tandoor_duplicate = client.find_duplicate(url, data.get("title", "")) except Exception: pass # non-fatal # Validate completeness warnings = [] real_ingredients = [i for i in data.get("ingredients", []) if "group" not in i] if not real_ingredients: warnings.append("A recept nem tartalmaz hozzávalókat.") instructions = data.get("instructions", []) if not instructions: warnings.append("A recept nem tartalmaz elkészítési lépéseket.") elif _is_redirect_instructions(instructions): warnings.append("Az elkészítés egy másik oldalra mutat. " "A recept valószínűleg nem tartalmaz valódi lépéseket.") return jsonify({"ok": True, "data": data, "duplicate": duplicate, "tandoor_duplicate": tandoor_duplicate, "warnings": warnings}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) @app.route("/send", methods=["POST"]) def send_to_mealie(): """AJAX — send edited recipe data to Mealie.""" cfg = config.load() if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): return jsonify({"ok": False, "error": "Mealie nincs beállítva."}) payload = request.get_json(silent=True) if not payload: return jsonify({"ok": False, "error": "Érvénytelen kérés."}) try: client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) slug = client.create_recipe(payload) recipe_url = f"{cfg['mealie_url']}/g/home/r/{slug}" return jsonify({"ok": True, "slug": slug, "url": recipe_url}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) @app.route("/send-tandoor", methods=["POST"]) def send_to_tandoor(): """AJAX — send edited recipe data to Tandoor.""" cfg = config.load() if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"): return jsonify({"ok": False, "error": "Tandoor nincs beállítva."}) payload = request.get_json(silent=True) if not payload: return jsonify({"ok": False, "error": "Érvénytelen kérés."}) try: client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) result = client.create_recipe(payload) return jsonify({"ok": True, "id": result["id"], "url": result["url"]}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) @app.route("/recipes") def recipes_page(): """Show the recipes management page.""" cfg = config.load() has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) if not has_mealie and not has_tandoor: flash("Először állíts be legalább egy szolgáltatást.", "warning") return redirect(url_for("settings")) return render_template("recipes.html", cfg=cfg, version=VERSION, has_mealie=has_mealie, has_tandoor=has_tandoor) @app.route("/recipes///edit") def recipe_edit_page(backend, recipe_id): """Show the recipe edit page.""" cfg = config.load() has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) return render_template("recipe_edit.html", cfg=cfg, version=VERSION, backend=backend, recipe_id=recipe_id, has_mealie=has_mealie, has_tandoor=has_tandoor) @app.route("/api/recipes/") def api_list_recipes(backend): """AJAX — list recipes from the given backend.""" cfg = config.load() search = request.args.get("search", "").strip() page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 50)) tag_ids_raw = request.args.get("tag_ids", "").strip() try: if backend == "mealie": if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): return jsonify({"ok": False, "error": "Mealie nincs beállítva."}) client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) tag_ids = [t for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None result = client.list_recipes(search=search, tag_ids=tag_ids, page=page, per_page=per_page) elif backend == "tandoor": if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"): return jsonify({"ok": False, "error": "Tandoor nincs beállítva."}) client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) tag_ids = [int(t) for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None result = client.list_recipes(search=search, keyword_ids=tag_ids, page=page, per_page=per_page) else: return jsonify({"ok": False, "error": "Ismeretlen backend."}) return jsonify({"ok": True, **result}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/api/recipes//") def api_get_recipe(backend, recipe_id): """AJAX — get a single recipe in common format.""" cfg = config.load() try: if backend == "mealie": client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) recipe = client.get_recipe(recipe_id) elif backend == "tandoor": client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) recipe = client.get_recipe(int(recipe_id)) else: return jsonify({"ok": False, "error": "Ismeretlen backend."}) return jsonify({"ok": True, "recipe": recipe}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/api/recipes//", methods=["PUT"]) def api_update_recipe(backend, recipe_id): """AJAX — update a single recipe.""" cfg = config.load() payload = request.get_json(silent=True) if not payload: return jsonify({"ok": False, "error": "Érvénytelen kérés."}) try: if backend == "mealie": client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) client.update_recipe(recipe_id, payload) elif backend == "tandoor": client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) client.update_recipe(int(recipe_id), payload) else: return jsonify({"ok": False, "error": "Ismeretlen backend."}) return jsonify({"ok": True, "message": "Recept sikeresen mentve."}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/api/recipes//delete", methods=["POST"]) def api_delete_recipes(backend): """AJAX — delete one or more recipes.""" cfg = config.load() payload = request.get_json(silent=True) if not payload or not payload.get("ids"): return jsonify({"ok": False, "error": "Nincs kiválasztott recept."}) ids = payload["ids"] errors = [] try: if backend == "mealie": client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) for slug in ids: try: client.delete_recipe(slug) except Exception as e: errors.append(f"{slug}: {e}") elif backend == "tandoor": client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) for rid in ids: try: client.delete_recipe(int(rid)) except Exception as e: errors.append(f"{rid}: {e}") else: return jsonify({"ok": False, "error": "Ismeretlen backend."}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) deleted = len(ids) - len(errors) if errors: return jsonify({"ok": True, "deleted": deleted, "errors": errors, "message": f"{deleted} recept törölve, {len(errors)} hiba."}) return jsonify({"ok": True, "deleted": deleted, "message": f"{deleted} recept sikeresen törölve."}) @app.route("/api/tags/") def api_list_tags(backend): """Return tags/keywords with IDs for a specific backend.""" cfg = config.load() try: if backend == "mealie": client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) tags = client.list_tags() elif backend == "tandoor": client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) tags = client.list_keywords() else: return jsonify({"ok": False, "error": "Ismeretlen backend."}) return jsonify({"ok": True, "tags": tags}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @app.route("/tags", methods=["GET"]) def list_all_tags(): """Return existing tags from Mealie and Tandoor for autocomplete.""" cfg = config.load() mealie_tags = [] tandoor_tags = [] if cfg.get("mealie_url") and cfg.get("mealie_api_key"): try: client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], api_url=config.MEALIE_INTERNAL_URL) mealie_tags = [t["name"] for t in client.list_tags()] except Exception: pass if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"): try: client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], api_url=config.TANDOOR_INTERNAL_URL) tandoor_tags = [t["name"] for t in client.list_keywords()] except Exception: pass return jsonify({"mealie": mealie_tags, "tandoor": tandoor_tags}) # --------------------------------------------------------------------------- # Assets # --------------------------------------------------------------------------- ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets") @app.route("/assets/") def serve_asset(filename): """Serve static assets (logo, etc.) from app/assets/.""" return send_from_directory(ASSETS_DIR, filename) # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- @app.route("/health") def health(): return jsonify({"status": "ok", "version": VERSION})