import hmac import os import secrets from datetime import datetime, timedelta, timezone from functools import wraps from flask import Flask, jsonify, redirect, render_template, request, session, url_for import automation import scheduler import storage APP_VERSION = "0.10.0" APP_VERSION_DATE = "2026-09-01" app = Flask(__name__) # `or default`, not `get(name, default)`: docker-compose.yml always *sets* # these vars, so a missing/blank value in .env arrives as an empty string # and the two-arg default never applies. An empty secret_key makes Flask # raise on every session write, and an empty LOGIN_PASSWORD would make # compare_digest("", "") return True — i.e. no login at all. app.secret_key = os.environ.get("SECRET_KEY") or "dev-secret-change-me" app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD") or "changeme" DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] DAY_LABELS = {"mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat", "sun": "Sun"} def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if not session.get("authenticated"): # The JSON API answers 401 instead of redirecting: fetch() # follows a 302 to /login, gets 200 HTML back, and the caller # reads res.ok as success — "Run Now" would report "Queued" # for a run that was never queued. if request.path.startswith("/api/"): return jsonify({"error": "Signed out — reload the page to log in again."}), 401 return redirect(url_for("login", next=request.path)) return view(*args, **kwargs) return wrapped def _csrf_token(): token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token return token def csrf_protect(view): """Header-based CSRF check for the JSON API. The token is minted per browser session, handed to the page in a tag, and echoed back by app.js in X-CSRF-Token — a cross-origin page can read neither the meta tag nor the session cookie's contents, so it can't forge the header, and a plain cross-site