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:
@@ -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
|
||||||
Reference in New Issue
Block a user