4467265168
When recipe instructions are just a link to another site (e.g. "ide kattintva, a Kiskegyed oldalán találod"), display a warning instead of silently accepting non-useful instruction text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""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("/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/<path:filename>")
|
|
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})
|