settings.py
Rama master · réplica de lectura del repositorio original.
"""Central configuration — the single source of truth for environment-driven settings."""
import logging
import os
from pathlib import Path
from typing import TypeVar, overload
_T = TypeVar("_T")
BASE_DIR = Path(__file__).resolve().parent
# ── Environment variable names ───────────────────────────────────────────────
#
# Every setting is read as FEEDECHO_*. The prefix shipped misspelled as
# FEEDCHO_ (one E) through v1.31.0 — issue #15 — so the legacy spelling is
# still honoured: the correct name wins when both are set, and each legacy
# name that actually supplied a value is recorded in LEGACY_ENV_IN_USE and
# named once by warn_legacy_env() at startup. That keeps an upgrade from
# silently un-configuring a running deployment (an unauthenticated UI, a
# database written to a different path) just because the operator's .env,
# systemd unit, or Nix module still uses the old name.
ENV_PREFIX = "FEEDECHO_"
LEGACY_ENV_PREFIX = "FEEDCHO_"
# (legacy name, canonical name) for every legacy variable read this process.
LEGACY_ENV_IN_USE: list[tuple[str, str]] = []
@overload
def env(name: str) -> str | None: ...
@overload
def env(name: str, default: _T) -> str | _T: ...
def env(name: str, default=None):
"""Read ``FEEDECHO_<name>``, falling back to the legacy ``FEEDCHO_<name>``.
``name`` is the suffix, e.g. ``env("AUTH_TOKEN")``. The default is returned
unchanged (not coerced), so callers keep their existing contracts — an
unset AUTH_TOKEN is still None, an unset DB_PATH is still a Path.
"""
canonical = ENV_PREFIX + name
legacy = LEGACY_ENV_PREFIX + name
if canonical in os.environ:
return os.environ[canonical]
if legacy in os.environ:
if (legacy, canonical) not in LEGACY_ENV_IN_USE:
LEGACY_ENV_IN_USE.append((legacy, canonical))
return os.environ[legacy]
return default
def warn_legacy_env() -> None:
"""Warn once, naming every deprecated FEEDCHO_ variable this process read.
Called from validate_config() at app startup rather than at import: the
logging configuration is in place by then, so the warning actually lands
in the operator's logs instead of on the logging lastResort handler.
"""
if not LEGACY_ENV_IN_USE:
return
logging.getLogger("feedecho").warning(
"Deprecated environment variable name(s) in use: %s. The %s prefix was "
"a misspelling (issue #15); rename to the %s form. The old names still "
"work but will be removed in a future release.",
", ".join(f"{old} -> {new}" for old, new in LEGACY_ENV_IN_USE),
LEGACY_ENV_PREFIX,
ENV_PREFIX,
)
MODE = env("MODE", "single")
if MODE not in ("single", "multi"):
raise ValueError(f"FEEDECHO_MODE must be 'single' or 'multi', got {MODE!r}")
MULTI = MODE == "multi"
DB_PATH = Path(env("DB_PATH", BASE_DIR / "feedecho.db"))
DATABASE_URL = env("DATABASE_URL", "")
# None when unset, matching the pre-refactor semantics exactly (all
# consumers use truthiness checks; empty string would be equivalent but
# None preserves the original contract).
AUTH_TOKEN = env("AUTH_TOKEN")
STATE_SECRET = env("STATE_SECRET", "")
BASE_URL = env("BASE_URL", "").strip()
# Where the source lives. Used as the last-resort website for the Mastodon
# OAuth app registration so posts never advertise a placeholder domain.
PROJECT_URL = "https://github.com/jcrabapple/feedecho"
# An unset callback URL derives from BASE_URL when one is configured; the
# example.com placeholder is only used when the deployment URL is unknown
# (where OAuth would fail loudly on redirect mismatch anyway).
CALLBACK_URL = env("CALLBACK_URL", "").strip() or (
f"{BASE_URL.rstrip('/')}/oauth/callback"
if BASE_URL
else "https://feedecho.example.com/oauth/callback"
)
# The "website" sent with the OAuth app registration. Mastodon renders it as
# the link behind the application name on every post, so it must resolve:
# an explicit override wins, then this deployment's own URL, then the repo.
APP_WEBSITE = (
env("APP_WEBSITE", "").strip()
or BASE_URL.rstrip("/")
or PROJECT_URL
)
ADMIN_EMAIL = env("ADMIN_EMAIL", "")
SESSION_SECRET = env("SESSION_SECRET", "")
# Fernet key for encrypting third-party credentials (Mastodon/Bluesky/Matrix/
# micro.blog/Discord/Telegram tokens, SMTP passwords, vision API keys, OAuth
# client secrets) at rest. Required in multi mode (validate_config refuses to
# boot without it); optional in single mode, where it protects copied backups
# of the database. Generate with `python -c "from cryptography.fernet
# import Fernet; print(Fernet.generate_key().decode())"`.
CREDENTIAL_KEY = env("CREDENTIAL_KEY", "")
ALLOW_SQLITE_FALLBACK = env("ALLOW_SQLITE_FALLBACK", "") == "1"
# Explicit opt-out of the single-mode auth requirement: boot with no
# FEEDECHO_AUTH_TOKEN and no authentication at all. Only sane behind the
# operator's own authenticated reverse proxy or on a private bind.
ALLOW_INSECURE = env("ALLOW_INSECURE", "") == "1"
# Force the Secure flag on session cookies when TLS terminates in front
# of the app (Caddy/nginx proxy): the request scheme then reads http
# even though the client connection is https.
FORCE_SECURE_COOKIE = env("FORCE_SECURE_COOKIE", "") == "1"
# Comma-separated CIDR list of trusted reverse proxies. When the direct
# peer is inside this list, client IPs are derived from X-Forwarded-For
# (rightmost entry) instead of the TCP peer, so rate limits see real
# client addresses instead of one shared proxy IP.
TRUSTED_PROXIES = tuple(
c.strip() for c in env("TRUSTED_PROXIES", "").split(",") if c.strip()
)
def _env_int(name: str, default: int) -> int:
"""env() parsed as int with a warning fallback (audit finding 3.8)."""
try:
return int(env(name, str(default)))
except ValueError:
logging.getLogger("feedecho").warning(
"FEEDECHO_%s is not a valid integer; using default of %s", name, default
)
return default
# Cap on concurrent scrypt hashes. scrypt releases the GIL, so concurrent
# login/register/reset attempts stack ~128 MiB each; this bounds peak memory.
# Default 4 (≈512 MiB). Tune down on small hosts, up on large ones.
SCRYPT_CONCURRENCY = max(1, _env_int("SCRYPT_CONCURRENCY", 4))
# ── Plan limits (hosted mode) ────────────────────────────────────────────────
#
# Per-plan resource caps. JSON via FEEDECHO_PLAN_LIMITS overrides defaults;
# unknown keys are kept, so adding a plan in env does not require a code
# change. Single mode is exempt from all of this (limits are only consulted
# through plans.limit_for(), which callers gate on settings.MULTI).
#
# Keys per plan:
# max_feeds — feeds the plan may have (0 = unlimited)
# min_poll_interval — lowest poll_interval in minutes the plan may set;
# the scheduler CLAMPS down-scoped values rather than
# rejecting, so plan downgrades never break existing
# feeds, they just poll less often
# max_destinations — total connected accounts across all types (0 = unlimited)
# max_posts_per_hour — drip ceiling for the plan (0 = unlimited)
# reader — 1 when the plan includes the RSS reader (issue #11)
DEFAULT_PLAN_LIMITS = {
"trial": {
"max_feeds": 5,
"min_poll_interval": 15,
"max_destinations": 5,
"max_posts_per_hour": 60,
"reader": 1,
"queue_depth": 10,
"saved_searches": 3,
},
"beta": {
"max_feeds": 25,
"min_poll_interval": 5,
"max_destinations": 15,
"max_posts_per_hour": 240,
"reader": 1,
"queue_depth": 100,
"saved_searches": 20,
},
"paid": {
"max_feeds": 50,
"min_poll_interval": 5,
"max_destinations": 50,
"max_posts_per_hour": 500,
"reader": 1,
"queue_depth": 500,
"saved_searches": 20,
},
}
def _load_plan_limits() -> dict:
import json
raw = env("PLAN_LIMITS", "").strip()
if not raw:
return {k: dict(v) for k, v in DEFAULT_PLAN_LIMITS.items()}
try:
overrides = json.loads(raw)
except ValueError:
logging.getLogger("feedecho").warning(
"FEEDECHO_PLAN_LIMITS is not valid JSON; using default plan limits"
)
return {k: dict(v) for k, v in DEFAULT_PLAN_LIMITS.items()}
if not isinstance(overrides, dict):
logging.getLogger("feedecho").warning(
"FEEDECHO_PLAN_LIMITS must be a JSON object; using default plan limits"
)
return {k: dict(v) for k, v in DEFAULT_PLAN_LIMITS.items()}
merged = {k: dict(v) for k, v in DEFAULT_PLAN_LIMITS.items()}
for plan, limits in overrides.items():
if isinstance(limits, dict):
merged.setdefault(plan, {}).update(
{k: v for k, v in limits.items() if isinstance(v, int) and v >= 0}
)
return merged
PLAN_LIMITS = _load_plan_limits()
# Trial handling: the plan column also carries 'single' semantics implicitly
# (single mode never checks). A trial whose trial_ends_at has passed is
# PAUSED by the scheduler (feeds skipped, cursor frozen — nothing is deleted)
# until the operator moves the user to 'beta'/'paid' or extends trial_ends_at.
TRIAL_GRACE_NOTE = env("TRIAL_GRACE_NOTE", "")
# ── Outbound Fallback Proxy ─────────────────────────────────────────────────
#
# Optional fallback proxy for outbound feed/image fetches that hit edge WAF
# blocks (e.g. CloudFront/Cloudflare HTTP 403/429 blocking datacenter IPs).
FALLBACK_PROXY_URL = env("FALLBACK_PROXY_URL", "").strip()
FALLBACK_PROXY_SECRET = env("FALLBACK_PROXY_SECRET", "").strip()
# ── FeedBooster ─────────────────────────────────────────────────────────────
#
# Optional URL + shared token of a FeedBooster instance (an ActivityPub
# service that announces echoed posts as @feedbooster@feedecho.net). When
# unconfigured, the per-account booster toggle is hidden and echoes are never
# boosted. The hosted deployment points at https://ap.feedecho.net; a
# self-hoster can run their own booster and point these at it.
BOOSTER_URL = env("BOOSTER_URL", "").strip().rstrip("/")
BOOSTER_TOKEN = env("BOOSTER_TOKEN", "").strip()
# ── Invite codes (hosted beta gate) ─────────────────────────────────────────
#
# When True, /register requires a valid unused invite code. Default is False
# so an upgrade never locks self-hosters out of their own instance (single
# mode ignores this entirely); the hosted deployment sets FEEDECHO_INVITES_REQUIRED=1.
INVITES_REQUIRED = env("INVITES_REQUIRED", "") == "1"
# ── Signup abuse controls ────────────────────────────────────────────────────
#
# Deployment-wide registration brake: the per-IP bucket in auth.py bounds a
# single source, but a rotating-IP spray has no ceiling at all, and every
# valid submission costs a scrypt hash plus one verification email to an
# address the registrant chooses. These ceilings bound the deployment as a
# whole. Tune with FEEDECHO_REGISTER_HOURLY_CAP / FEEDECHO_REGISTER_DAILY_CAP.
REGISTER_HOURLY_CAP = max(1, _env_int("REGISTER_HOURLY_CAP", 30))
REGISTER_DAILY_CAP = max(1, _env_int("REGISTER_DAILY_CAP", 300))
# Screen known disposable/burner email domains at registration. Multi mode is
# the only mode with a public /register, so single mode is unaffected. Default
# ON; set FEEDECHO_DISPOSABLE_EMAIL_BLOCK=0 to accept every domain.
DISPOSABLE_EMAIL_BLOCK = env("DISPOSABLE_EMAIL_BLOCK", "1") != "0"
# ── Billing (hosted-only seam) ────────────────────────────────────────────────
#
# When True, multi-mode templates render billing UI (a "Subscribe" CTA on the
# trial banner and a Billing section on /settings). The actual payment routes
# (/api/billing/*) are NOT part of this repo — the hosted deployment mounts
# them from its private billing module — so this flag only toggles the
# front-end seam and never implies Stripe here. The hosted image sets
# FEEDECHO_BILLING_ENABLED=1.
BILLING_ENABLED = env("BILLING_ENABLED", "") == "1"
# Abandoned-signup cleanup: a card-pending account (registered while billing
# is on, checkout never completed) older than this many days is deleted by
# the scheduler cleanup job. Hosted only: the job no-ops unless multi mode
# and billing are both on. Tune with FEEDECHO_PENDING_ACCOUNT_TTL_DAYS.
PENDING_ACCOUNT_TTL_DAYS = max(1, _env_int("PENDING_ACCOUNT_TTL_DAYS", 3))
# ── Backdated entries ─────────────────────────────────────────────────────────
#
# When True, feed items that appear positionally OLDER than the cursor (so the
# position-based scan skips them) but carry a publish date within
# MAX_BACKDATED_ENTRY_DAYS of now are still delivered. Off by default so
# existing behaviour is unchanged; self-hosters who backdate posts can opt in.
ALLOW_BACKDATED_ENTRIES = env("ALLOW_BACKDATED_ENTRIES", "") == "1"
MAX_BACKDATED_ENTRY_DAYS = _env_int("MAX_BACKDATED_ENTRY_DAYS", 3)
# ── Reader (RSS reading surface, issue #11) ──────────────────────────────────
#
# The reader persists feed items for reading; this caps how many are kept per
# feed (oldest pruned on insert) so a shared hosted database cannot grow
# without bound. Single mode uses the same default and may tune it via env.
READER_MAX_ITEMS_PER_FEED = _env_int("READER_MAX_ITEMS_PER_FEED", 200)
READER_MAX_STARRED_PER_FEED = _env_int("READER_MAX_STARRED_PER_FEED", 0)
def validate_config() -> None:
"""Fail fast on misconfiguration. Called from app startup.
Single mode refuses to boot without FEEDECHO_AUTH_TOKEN unless the
operator explicitly opts out with FEEDECHO_ALLOW_INSECURE=1 — a bare
`docker run` used to start fully unauthenticated while binding 0.0.0.0.
Multi mode requires DATABASE_URL / SESSION_SECRET / STATE_SECRET /
CREDENTIAL_KEY. A set-but-invalid CREDENTIAL_KEY is rejected in BOTH
modes.
"""
# Runs in both modes: a self-hoster on the legacy names deserves the
# rename notice even though nothing else here applies to single mode.
warn_legacy_env()
if CREDENTIAL_KEY:
# A set-but-invalid key would otherwise only crash on the first
# encrypt/decrypt (mid-request, as a 500). Fail fast at startup.
try:
from cryptography.fernet import Fernet
Fernet(CREDENTIAL_KEY.encode("utf-8"))
except Exception as exc:
raise RuntimeError(
"FEEDECHO_CREDENTIAL_KEY is not a valid Fernet key "
f"({exc}). Generate one with: python -c "
"\"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\""
)
if not MULTI:
if not AUTH_TOKEN:
if not ALLOW_INSECURE:
raise RuntimeError(
"FEEDECHO_AUTH_TOKEN is not set: the web UI and API "
"would be open to anyone who can reach the port. Set "
"FEEDECHO_AUTH_TOKEN to a long random string, or set "
"FEEDECHO_ALLOW_INSECURE=1 to run without authentication "
"(only sane behind your own authenticated reverse proxy "
"or on a private bind)."
)
logging.getLogger("feedecho").warning(
"FEEDECHO_AUTH_TOKEN is unset and FEEDECHO_ALLOW_INSECURE=1: "
"authentication is DISABLED — anyone who can reach this "
"port has full access."
)
return
if not DATABASE_URL and not ALLOW_SQLITE_FALLBACK:
raise RuntimeError(
"FEEDECHO_DATABASE_URL must be set when FEEDECHO_MODE=multi "
"(or set FEEDECHO_ALLOW_SQLITE_FALLBACK=1 for local development)"
)
if not SESSION_SECRET:
raise RuntimeError(
"FEEDECHO_SESSION_SECRET must be set when FEEDECHO_MODE=multi"
)
if len(SESSION_SECRET) < 32:
raise RuntimeError(
"FEEDECHO_SESSION_SECRET must be at least 32 characters in multi mode"
)
if not STATE_SECRET:
# Without this, oauth._state_secret() would otherwise need to fall
# back to FEEDECHO_AUTH_TOKEN (a carried-over single-mode value with
# no minimum-length requirement) to key OAuth-state HMACs — that
# fallback is itself gated to raise, but only at first OAuth use;
# catching it here fails loudly at startup instead.
raise RuntimeError(
"FEEDECHO_STATE_SECRET must be set when FEEDECHO_MODE=multi"
)
if len(STATE_SECRET) < 32:
raise RuntimeError(
"FEEDECHO_STATE_SECRET must be at least 32 characters in multi mode"
)
if not BASE_URL:
# Deliberately a warning, not a raise: unlike DATABASE_URL and
# SESSION_SECRET, an unset BASE_URL degrades one feature (the links in
# verification and reset email) rather than risking data or auth, and
# raising here would turn an upgrade into an outage for any existing
# multi-mode self-hoster running without it. auth.py refuses to send
# those emails rather than mailing a relative link nobody can open.
logging.getLogger("feedecho").warning(
"FEEDECHO_BASE_URL is not set: account verification and password "
"reset emails cannot be sent (their links would be relative)."
)
if not CREDENTIAL_KEY:
# A missing key undoes S3 (the credential-encryption work) silently:
# multi mode boots with every third-party credential stored plaintext,
# and nothing fails or alerts until a breach. In a real multi-mode
# deployment (real database, no sqlite fallback) that must be a
# hard failure. The sqlite-fallback path is local development, where
# plaintext is acceptable for the same reason it is in single mode —
# the operator owns the database.
if not ALLOW_SQLITE_FALLBACK:
raise RuntimeError(
"FEEDECHO_CREDENTIAL_KEY must be set when FEEDECHO_MODE=multi "
"(or set FEEDECHO_ALLOW_SQLITE_FALLBACK=1 for local development). "
"Generate one with: python -c \"from cryptography.fernet import "
"Fernet; print(Fernet.generate_key().decode())\""
)
logging.getLogger("feedecho").warning(
"FEEDECHO_CREDENTIAL_KEY is not set: third-party account "
"credentials (Mastodon/Bluesky/Matrix/micro.blog/Discord tokens, "
"SMTP passwords, vision API keys) are stored in PLAINTEXT. Set "
"it to a Fernet key to encrypt them at rest."
)