The backend image now builds the SPA and serves it on the API's own origin (fluksio/spa.py), so an instance is a single container with no proxy, DNS, port 80 or .env: `docker compose up -d` with the new compose.yml runs it on localhost:8080. It starts on fluksio serve (persisted secret key, first admin printed once) and brings its own Redis when REDIS_HOST is unset. Slimmer: python:3.13-slim, no dev group, uv once, fastapi without the cloud CLI. Optional features are per-feature extras (mqtt, influx, email, sentry, ml, server for all) picked with the EXTRAS build argument; numpy moved to `ml`. ffmpeg left the image for imageio-ffmpeg via the Modules screen. The operator stack runs the same image with app. and api. routed to it; the nginx frontend service, the LAN nginx proxy and DOMAIN in the backend are gone, and the MCP issuer is FRONTEND_HOST.
157 lines
5.0 KiB
Python
157 lines
5.0 KiB
Python
import hashlib
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import jwt
|
|
from jinja2 import Template
|
|
from jwt.exceptions import InvalidTokenError
|
|
|
|
from fluksio.core import security
|
|
from fluksio.core.config import settings
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: How long to wait on the mail server before giving up on one message.
|
|
SMTP_TIMEOUT_S = 10
|
|
|
|
|
|
@dataclass
|
|
class EmailData:
|
|
html_content: str
|
|
subject: str
|
|
|
|
|
|
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
|
template_str = (
|
|
Path(__file__).parent / "email-templates" / "build" / template_name
|
|
).read_text()
|
|
html_content = Template(template_str).render(context)
|
|
return html_content
|
|
|
|
|
|
def send_email(
|
|
*,
|
|
email_to: str,
|
|
subject: str = "",
|
|
html_content: str = "",
|
|
) -> None:
|
|
assert settings.emails_enabled, "no provided configuration for email variables"
|
|
try:
|
|
import emails # type: ignore
|
|
except ImportError:
|
|
raise RuntimeError(
|
|
"sending mail needs the email extra: pip install 'fluksio[email]', "
|
|
"or build the image with EXTRAS=email"
|
|
) from None
|
|
message = emails.Message(
|
|
subject=subject,
|
|
html=html_content,
|
|
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
|
|
)
|
|
# With no timeout smtplib inherits the socket default, which is none at
|
|
# all — an unreachable mail host then held a threadpool worker until the
|
|
# kernel gave up, from a route anybody can call.
|
|
smtp_options: dict[str, Any] = {
|
|
"host": settings.SMTP_HOST,
|
|
"port": settings.SMTP_PORT,
|
|
"timeout": SMTP_TIMEOUT_S,
|
|
}
|
|
if settings.SMTP_TLS:
|
|
smtp_options["tls"] = True
|
|
elif settings.SMTP_SSL:
|
|
smtp_options["ssl"] = True
|
|
if settings.SMTP_USER:
|
|
smtp_options["user"] = settings.SMTP_USER
|
|
if settings.SMTP_PASSWORD:
|
|
smtp_options["password"] = settings.SMTP_PASSWORD
|
|
response = message.send(to=email_to, smtp=smtp_options)
|
|
logger.info(f"send email result: {response}")
|
|
|
|
|
|
def generate_test_email(email_to: str) -> EmailData:
|
|
project_name = settings.PROJECT_NAME
|
|
subject = f"{project_name} - Test email"
|
|
html_content = render_email_template(
|
|
template_name="test_email.html",
|
|
context={"project_name": settings.PROJECT_NAME, "email": email_to},
|
|
)
|
|
return EmailData(html_content=html_content, subject=subject)
|
|
|
|
|
|
def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
|
|
project_name = settings.PROJECT_NAME
|
|
subject = f"{project_name} - Password recovery for user {email}"
|
|
link = f"{settings.FRONTEND_HOST}/reset-password?token={token}"
|
|
html_content = render_email_template(
|
|
template_name="reset_password.html",
|
|
context={
|
|
"project_name": settings.PROJECT_NAME,
|
|
"username": email,
|
|
"email": email_to,
|
|
"valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
|
|
"link": link,
|
|
},
|
|
)
|
|
return EmailData(html_content=html_content, subject=subject)
|
|
|
|
|
|
def generate_new_account_email(
|
|
email_to: str, username: str, password: str
|
|
) -> EmailData:
|
|
project_name = settings.PROJECT_NAME
|
|
subject = f"{project_name} - New account for user {username}"
|
|
html_content = render_email_template(
|
|
template_name="new_account.html",
|
|
context={
|
|
"project_name": settings.PROJECT_NAME,
|
|
"username": username,
|
|
"password": password,
|
|
"email": email_to,
|
|
"link": settings.FRONTEND_HOST,
|
|
},
|
|
)
|
|
return EmailData(html_content=html_content, subject=subject)
|
|
|
|
|
|
def password_fingerprint(hashed_password: str) -> str:
|
|
"""A short digest of the stored hash, for binding a reset link to it.
|
|
|
|
What makes a reset token single-use without a table of spent ones: the
|
|
hash changes the moment the password does, so the link that set it stops
|
|
verifying. It was replayable for the whole 48 hours otherwise.
|
|
"""
|
|
return hashlib.sha256(hashed_password.encode()).hexdigest()[:16]
|
|
|
|
|
|
def generate_password_reset_token(email: str, hashed_password: str = "") -> str:
|
|
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
|
|
now = datetime.now(UTC)
|
|
expires = now + delta
|
|
exp = expires.timestamp()
|
|
encoded_jwt = jwt.encode(
|
|
{
|
|
"exp": exp,
|
|
"nbf": now,
|
|
"sub": email,
|
|
"pwd": password_fingerprint(hashed_password),
|
|
},
|
|
settings.SECRET_KEY,
|
|
algorithm=security.ALGORITHM,
|
|
)
|
|
return encoded_jwt
|
|
|
|
|
|
def verify_password_reset_token(token: str) -> tuple[str, str] | None:
|
|
"""The email the token names and the password hash it was minted against."""
|
|
try:
|
|
decoded_token = jwt.decode(
|
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
|
)
|
|
return str(decoded_token["sub"]), str(decoded_token.get("pwd", ""))
|
|
except InvalidTokenError:
|
|
return None
|