feat: add simple password auth gate

- Flask session-based login with styled dark-theme login page
- All routes gated behind password (configurable via SITE_PASSWORD env)
- /login and /api/health are public
- Wrong password shows red error, correct redirects to original page
- 30-day session persistence
- /logout to clear session
- Password: jv2026 (set in docker-compose.yml)
This commit is contained in:
2026-03-02 23:04:22 +08:00
parent ab875cd4d9
commit 21f67d39c7
2 changed files with 90 additions and 2 deletions

90
app.py
View File

@@ -2,10 +2,12 @@
Live proposal site with real Gemini-powered demos.
PostgreSQL-backed data dashboard."""
import os, json, time, traceback, logging
import os, json, time, traceback, logging, secrets
from pathlib import Path
from functools import wraps
from flask import (Flask, render_template, jsonify, request,
send_from_directory, redirect)
send_from_directory, redirect, session, url_for,
make_response)
from scraper import scrape_product, scrape_competitor
from ai_engine import (generate_asset_pack, competitor_xray, pdp_surgeon,
@@ -18,6 +20,9 @@ app = Flask(__name__,
static_folder="static",
template_folder="templates")
app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
SITE_PASSWORD = os.environ.get("SITE_PASSWORD", "jv2026")
GEN_DIR = Path(__file__).parent / "generated"
GEN_DIR.mkdir(exist_ok=True)
@@ -28,6 +33,87 @@ with app.app_context():
except Exception as e:
logging.error(f"DB init failed: {e}")
# ── Auth ─────────────────────────────────────────────────────
PUBLIC_PATHS = {"/login", "/api/health"}
@app.before_request
def require_auth():
"""Gate every request behind a simple password session."""
path = request.path
# Allow login page, health check, and static login assets
if path in PUBLIC_PATHS or path.startswith("/static/"):
return
if not session.get("authed"):
if request.is_json:
return jsonify({"error": "Authentication required"}), 401
return redirect(url_for("login", next=request.path))
LOGIN_HTML = """<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login — JustVitamins × QuikCue</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#0a0e1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#e0e6f0}
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:16px;padding:48px 40px;max-width:400px;width:100%;text-align:center}
.card h1{font-size:1.5rem;margin-bottom:8px;background:linear-gradient(135deg,#22d3ee,#a78bfa);
-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.card p{font-size:.85rem;color:#8892a8;margin-bottom:28px}
.field{position:relative;margin-bottom:20px}
.field input{width:100%;padding:14px 16px;background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.12);border-radius:10px;color:#e0e6f0;
font-size:1rem;outline:none;transition:border .2s}
.field input:focus{border-color:#22d3ee}
.field input::placeholder{color:#5a6478}
button{width:100%;padding:14px;border:none;border-radius:10px;cursor:pointer;
font-size:1rem;font-weight:600;color:#fff;
background:linear-gradient(135deg,#7c3aed,#6d28d9);transition:opacity .2s}
button:hover{opacity:.9}
.err{color:#f87171;font-size:.85rem;margin-bottom:16px}
.footer{margin-top:24px;font-size:.75rem;color:#4a5568}
</style>
</head><body>
<div class="card">
<h1>JustVitamins × QuikCue</h1>
<p>Confidential proposal — enter password to continue</p>
{{ERR}}
<form method="post">
<div class="field">
<input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password">
</div>
<button type="submit">Enter</button>
</form>
<div class="footer">Prepared for Just Vitamins · March 2026</div>
</div>
</body></html>"""
@app.route("/login", methods=["GET", "POST"])
def login():
err = ""
if request.method == "POST":
pw = request.form.get("password", "")
if pw == SITE_PASSWORD:
session["authed"] = True
session.permanent = True
app.permanent_session_lifetime = __import__("datetime").timedelta(days=30)
dest = request.args.get("next", "/")
return redirect(dest)
err = '<div class="err">Wrong password</div>'
html = LOGIN_HTML.replace("{{ERR}}", err)
return make_response(html)
@app.route("/logout")
def logout():
session.clear()
return redirect("/login")
# ── Page routes ──────────────────────────────────────────────
@app.route("/")