Ctrl K
ComunidadGuías…

settings.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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."
        )