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. Live proposal site with real Gemini-powered demos.
PostgreSQL-backed data dashboard.""" PostgreSQL-backed data dashboard."""
import os, json, time, traceback, logging import os, json, time, traceback, logging, secrets
from pathlib import Path from pathlib import Path
from functools import wraps
from flask import (Flask, render_template, jsonify, request, 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 scraper import scrape_product, scrape_competitor
from ai_engine import (generate_asset_pack, competitor_xray, pdp_surgeon, from ai_engine import (generate_asset_pack, competitor_xray, pdp_surgeon,
@@ -18,6 +20,9 @@ app = Flask(__name__,
static_folder="static", static_folder="static",
template_folder="templates") 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 = Path(__file__).parent / "generated"
GEN_DIR.mkdir(exist_ok=True) GEN_DIR.mkdir(exist_ok=True)
@@ -28,6 +33,87 @@ with app.app_context():
except Exception as e: except Exception as e:
logging.error(f"DB init failed: {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 ────────────────────────────────────────────── # ── Page routes ──────────────────────────────────────────────
@app.route("/") @app.route("/")

View File

@@ -7,6 +7,8 @@ services:
environment: environment:
- GEMINI_API_KEY=AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E - GEMINI_API_KEY=AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E
- DATABASE_URL=postgresql://jv:jvpass@tasks.db:5432/justvitamin - DATABASE_URL=postgresql://jv:jvpass@tasks.db:5432/justvitamin
- SECRET_KEY=c4f8a2e91b0d7f3e5a6c9d2b8e1f4a7d
- SITE_PASSWORD=jv2026
volumes: volumes:
- jv-generated:/app/generated - jv-generated:/app/generated
networks: networks: