feat(secrets): update core.py dotenv to load from repo root .env

Load .env from repo root first (created by `make secrets-decrypt-dev`),
falling back to web/.env for legacy setups. Also fixes import sort order
and removes unused httpx import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 10:47:34 +01:00
parent 6d716a83ae
commit 643c0b2db9

View File

@@ -1,29 +1,33 @@
""" """
Core infrastructure: database, config, email, and shared utilities. Core infrastructure: database, config, email, and shared utilities.
""" """
import hashlib
import hmac
import os import os
import random import random
import secrets import secrets
import hashlib from contextvars import ContextVar
import hmac from datetime import datetime, timedelta
from functools import wraps
from pathlib import Path
import aiosqlite import aiosqlite
import resend import resend
import httpx
from pathlib import Path
from functools import wraps
from datetime import datetime, timedelta
from contextvars import ContextVar
from quart import g, make_response, render_template, request, session
from dotenv import load_dotenv from dotenv import load_dotenv
from quart import g, make_response, render_template, request, session
# web/.env is three levels up from web/src/beanflows/core.py # Load .env from repo root first (created by `make secrets-decrypt-dev`),
load_dotenv(Path(__file__).parent.parent.parent / ".env", override=False) # fall back to web/.env for legacy local dev setups.
_repo_root = Path(__file__).parent.parent.parent.parent
load_dotenv(_repo_root / ".env", override=False)
load_dotenv(_repo_root / "web" / ".env", override=False)
# ============================================================================= # =============================================================================
# Configuration # Configuration
# ============================================================================= # =============================================================================
class Config: class Config:
APP_NAME: str = os.getenv("APP_NAME", "BeanFlows") APP_NAME: str = os.getenv("APP_NAME", "BeanFlows")
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production") SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
@@ -53,7 +57,9 @@ class Config:
ADMIN_EMAILS: list[str] = [ ADMIN_EMAILS: list[str] = [
e.strip().lower() e.strip().lower()
for e in os.getenv("ADMIN_EMAILS", "hendrik@beanflow.coffee,simon@beanflows.coffee").split(",") for e in os.getenv("ADMIN_EMAILS", "hendrik@beanflow.coffee,simon@beanflows.coffee").split(
","
)
if e.strip() if e.strip()
] ]
@@ -66,7 +72,14 @@ class Config:
PLAN_FEATURES: dict = { PLAN_FEATURES: dict = {
"free": ["dashboard", "coffee_only", "limited_history"], "free": ["dashboard", "coffee_only", "limited_history"],
"starter": ["dashboard", "coffee_only", "full_history", "export", "api"], "starter": ["dashboard", "coffee_only", "full_history", "export", "api"],
"pro": ["dashboard", "all_commodities", "full_history", "export", "api", "priority_support"], "pro": [
"dashboard",
"all_commodities",
"full_history",
"export",
"api",
"priority_support",
],
} }
PLAN_LIMITS: dict = { PLAN_LIMITS: dict = {
@@ -165,6 +178,7 @@ class transaction:
await self.db.rollback() await self.db.rollback()
return False return False
# ============================================================================= # =============================================================================
# Email # Email
# ============================================================================= # =============================================================================
@@ -175,8 +189,12 @@ EMAIL_ADDRESSES = {
async def send_email( async def send_email(
to: str, subject: str, html: str, text: str = None, to: str,
from_addr: str = None, template: str = None, subject: str,
html: str,
text: str = None,
from_addr: str = None,
template: str = None,
) -> bool: ) -> bool:
"""Send email via Resend SDK and log to email_log table.""" """Send email via Resend SDK and log to email_log table."""
if not config.RESEND_API_KEY: if not config.RESEND_API_KEY:
@@ -191,13 +209,15 @@ async def send_email(
provider_id = None provider_id = None
error_msg = None error_msg = None
try: try:
result = resend.Emails.send({ result = resend.Emails.send(
"from": from_addr or config.EMAIL_FROM, {
"to": to, "from": from_addr or config.EMAIL_FROM,
"subject": subject, "to": to,
"html": html, "subject": subject,
"text": text or html, "html": html,
}) "text": text or html,
}
)
provider_id = result.get("id") if isinstance(result, dict) else None provider_id = result.get("id") if isinstance(result, dict) else None
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
@@ -206,15 +226,24 @@ async def send_email(
await execute( await execute(
"""INSERT INTO email_log (recipient, subject, template, status, provider_id, error, created_at) """INSERT INTO email_log (recipient, subject, template, status, provider_id, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(to, subject, template, "error" if error_msg else "sent", (
provider_id, error_msg, datetime.utcnow().isoformat()), to,
subject,
template,
"error" if error_msg else "sent",
provider_id,
error_msg,
datetime.utcnow().isoformat(),
),
) )
return error_msg is None return error_msg is None
# ============================================================================= # =============================================================================
# CSRF Protection # CSRF Protection
# ============================================================================= # =============================================================================
def get_csrf_token() -> str: def get_csrf_token() -> str:
"""Get or create CSRF token for current session.""" """Get or create CSRF token for current session."""
if "csrf_token" not in session: if "csrf_token" not in session:
@@ -229,6 +258,7 @@ def validate_csrf_token(token: str) -> bool:
def csrf_protect(f): def csrf_protect(f):
"""Decorator to require valid CSRF token for POST requests.""" """Decorator to require valid CSRF token for POST requests."""
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
if request.method == "POST": if request.method == "POST":
@@ -237,12 +267,15 @@ def csrf_protect(f):
if not validate_csrf_token(token): if not validate_csrf_token(token):
return {"error": "Invalid CSRF token"}, 403 return {"error": "Invalid CSRF token"}, 403
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
# ============================================================================= # =============================================================================
# Rate Limiting (SQLite-based) # Rate Limiting (SQLite-based)
# ============================================================================= # =============================================================================
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]: async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
""" """
Check if rate limit exceeded. Returns (is_allowed, info). Check if rate limit exceeded. Returns (is_allowed, info).
@@ -255,13 +288,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
# Clean old entries and count recent # Clean old entries and count recent
await execute( await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
(key, window_start.isoformat())
) )
result = await fetch_one( result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat()) (key, window_start.isoformat()),
) )
count = result["count"] if result else 0 count = result["count"] if result else 0
@@ -275,16 +307,14 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
return False, info return False, info
# Record this request # Record this request
await execute( await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.isoformat())
)
return True, info return True, info
def rate_limit(limit: int = None, window: int = None, key_func=None): def rate_limit(limit: int = None, window: int = None, key_func=None):
"""Decorator for rate limiting routes.""" """Decorator for rate limiting routes."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
@@ -300,9 +330,12 @@ def rate_limit(limit: int = None, window: int = None, key_func=None):
return response, 429 return response, 429
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator
# ============================================================================= # =============================================================================
# Request ID Tracking # Request ID Tracking
# ============================================================================= # =============================================================================
@@ -317,6 +350,7 @@ def get_request_id() -> str:
def setup_request_id(app): def setup_request_id(app):
"""Setup request ID middleware.""" """Setup request ID middleware."""
@app.before_request @app.before_request
async def set_request_id(): async def set_request_id():
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8) rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
@@ -328,34 +362,35 @@ def setup_request_id(app):
response.headers["X-Request-ID"] = get_request_id() response.headers["X-Request-ID"] = get_request_id()
return response return response
# ============================================================================= # =============================================================================
# Webhook Signature Verification # Webhook Signature Verification
# ============================================================================= # =============================================================================
def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool: def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 webhook signature.""" """Verify HMAC-SHA256 webhook signature."""
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected) return hmac.compare_digest(signature, expected)
# ============================================================================= # =============================================================================
# Soft Delete Helpers # Soft Delete Helpers
# ============================================================================= # =============================================================================
async def soft_delete(table: str, id: int) -> bool: async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted.""" """Mark record as deleted."""
result = await execute( result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id) (datetime.utcnow().isoformat(), id),
) )
return result > 0 return result > 0
async def restore(table: str, id: int) -> bool: async def restore(table: str, id: int) -> bool:
"""Restore soft-deleted record.""" """Restore soft-deleted record."""
result = await execute( result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,))
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
(id,)
)
return result > 0 return result > 0
@@ -369,8 +404,7 @@ async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago.""" """Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
return await execute( return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
(cutoff,)
) )
@@ -378,8 +412,10 @@ async def purge_deleted(table: str, days: int = 30) -> int:
# Waitlist # Waitlist
# ============================================================================= # =============================================================================
def waitlist_gate(template: str, **extra_context): def waitlist_gate(template: str, **extra_context):
"""Intercept GET requests when WAITLIST_MODE=true and render the waitlist template.""" """Intercept GET requests when WAITLIST_MODE=true and render the waitlist template."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
@@ -389,7 +425,9 @@ def waitlist_gate(template: str, **extra_context):
ctx[k] = v() if callable(v) else v ctx[k] = v() if callable(v) else v
return await render_template(template, **ctx) return await render_template(template, **ctx)
return await f(*args, **kwargs) return await f(*args, **kwargs)
return wrapper return wrapper
return decorator return decorator
@@ -411,16 +449,19 @@ async def capture_waitlist_email(
if result: if result:
from .worker import enqueue from .worker import enqueue
await enqueue("send_waitlist_confirmation", {"email": email}) await enqueue("send_waitlist_confirmation", {"email": email})
if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY: if config.RESEND_AUDIENCE_WAITLIST and config.RESEND_API_KEY:
try: try:
resend.api_key = config.RESEND_API_KEY resend.api_key = config.RESEND_API_KEY
resend.Contacts.create({ resend.Contacts.create(
"email": email, {
"audience_id": config.RESEND_AUDIENCE_WAITLIST, "email": email,
"unsubscribed": False, "audience_id": config.RESEND_AUDIENCE_WAITLIST,
}) "unsubscribed": False,
}
)
except Exception as e: except Exception as e:
print(f"[WAITLIST] Resend audience error: {e}") print(f"[WAITLIST] Resend audience error: {e}")
@@ -432,8 +473,10 @@ async def capture_waitlist_email(
# A/B Testing # A/B Testing
# ============================================================================= # =============================================================================
def ab_test(experiment: str, variants: tuple = ("control", "treatment")): def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
"""Assign visitor to an A/B test variant via cookie, tag Umami pageviews.""" """Assign visitor to an A/B test variant via cookie, tag Umami pageviews."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
@@ -448,7 +491,9 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
response = await make_response(await f(*args, **kwargs)) response = await make_response(await f(*args, **kwargs))
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60) response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response return response
return wrapper return wrapper
return decorator return decorator
@@ -456,6 +501,7 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
# Feature Flags (DB-backed, admin-toggleable) # Feature Flags (DB-backed, admin-toggleable)
# ============================================================================= # =============================================================================
async def is_flag_enabled(name: str, default: bool = False) -> bool: async def is_flag_enabled(name: str, default: bool = False) -> bool:
"""Check if a feature flag is enabled. Falls back to default if not found.""" """Check if a feature flag is enabled. Falls back to default if not found."""
row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (name,)) row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (name,))
@@ -482,6 +528,7 @@ async def get_all_flags() -> list[dict]:
def feature_gate(flag_name: str, fallback_template: str, **extra_context): def feature_gate(flag_name: str, fallback_template: str, **extra_context):
"""Gate a route behind a feature flag; renders fallback on GET, 403 on POST.""" """Gate a route behind a feature flag; renders fallback on GET, 403 on POST."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
@@ -491,5 +538,7 @@ def feature_gate(flag_name: str, fallback_template: str, **extra_context):
return await render_template(fallback_template, **ctx) return await render_template(fallback_template, **ctx)
return {"error": "Feature not available"}, 403 return {"error": "Feature not available"}, 403
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator