"""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_``, falling back to the legacy ``FEEDCHO_``. ``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." )