Files
app/backend/fluksio/utils.py
T
stroblme dea25ec08d One image for the whole app: engine, API and dashboard on one port
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.
2026-10-16 11:40:02 +02:00

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