Add a VERSION file (0.1.0) and inject APP_VERSION/GIT_SHA/BUILD_DATE as Docker build-args (both stages; Vite inlines them into the SPA). New public GET /api/version returns app_version, git_sha, build_date and the Alembic head as the database revision. deploy.sh and the localdev build pass the args.
117 lines
5.1 KiB
Python
117 lines
5.1 KiB
Python
from pydantic import model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
# Shipped placeholder; production must override it. Used by the startup guard below.
|
|
_DEFAULT_SECRET_KEY = "change-me-session-key"
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
app_name: str = "Siftlode"
|
|
# Build/version info, injected at image build time (Docker build-args -> env). Defaults
|
|
# apply for un-built/dev runs; the About dialog and version banner surface these.
|
|
app_version: str = "dev"
|
|
git_sha: str = "unknown"
|
|
build_date: str = ""
|
|
|
|
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
|
|
|
|
# Session cookie signing key.
|
|
secret_key: str = _DEFAULT_SECRET_KEY
|
|
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
|
|
token_encryption_key: str = ""
|
|
|
|
google_client_id: str = ""
|
|
google_client_secret: str = ""
|
|
oauth_redirect_url: str = "http://localhost:8080/auth/callback"
|
|
|
|
# Comma-separated invite list / admin list of Google account emails.
|
|
allowed_emails: str = ""
|
|
admin_emails: str = ""
|
|
|
|
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
|
frontend_origin: str = ""
|
|
|
|
# --- Outbound email (onboarding: access-request + approval notices) ---
|
|
# Gmail SMTP + App Password by default. All optional: if unset, email is skipped
|
|
# (fail-soft) and onboarding still works via in-app notifications.
|
|
smtp_host: str = ""
|
|
smtp_port: int = 587
|
|
smtp_user: str = ""
|
|
smtp_password: str = ""
|
|
smtp_from: str = "" # e.g. "Siftlode <addr@gmail.com>"; falls back to smtp_user
|
|
|
|
# --- Sync / YouTube Data API ---
|
|
# Optional API key for public reads (channels/videos/playlistItems). When set it is
|
|
# preferred for shared backfill/enrichment so it doesn't depend on a specific user.
|
|
youtube_api_key: str = ""
|
|
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
|
|
quota_daily_budget: int = 9000
|
|
# Recent-first backfill: how far back to fetch on the first pass per channel.
|
|
backfill_recent_max_videos: int = 100
|
|
backfill_recent_max_days: int = 365
|
|
|
|
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
|
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
|
shorts_probe_max_seconds: int = 180
|
|
shorts_probe_batch: int = 150
|
|
shorts_probe_interval_minutes: int = 2
|
|
# videos.list accepts up to 50 ids per call.
|
|
enrich_batch_size: int = 50
|
|
|
|
# --- Background scheduler ---
|
|
scheduler_enabled: bool = True
|
|
rss_poll_minutes: int = 20
|
|
enrich_interval_minutes: int = 3
|
|
backfill_interval_minutes: int = 10
|
|
# Keep this many quota units in reserve so scheduled backfill never starves
|
|
# interactive syncs / enrichment of fresh videos.
|
|
backfill_quota_reserve: int = 2000
|
|
|
|
# --- Auto-tagging / feed defaults ---
|
|
# Number of recent video titles sampled per channel for language detection.
|
|
autotag_title_sample: int = 40
|
|
autotag_interval_minutes: int = 30
|
|
subscriptions_resync_minutes: int = 360
|
|
# live_status values hidden from the feed by default. Completed-stream VODs
|
|
# ("was_live") are real watchable content and stay visible.
|
|
feed_default_hidden_live: str = "live,upcoming"
|
|
|
|
@property
|
|
def allowed_email_set(self) -> set[str]:
|
|
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
|
|
|
@property
|
|
def admin_email_set(self) -> set[str]:
|
|
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
|
|
|
@property
|
|
def session_https_only(self) -> bool:
|
|
"""Mark the session cookie Secure when we're served over HTTPS. We treat an
|
|
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
|
|
return self.oauth_redirect_url.lower().startswith("https://")
|
|
|
|
@model_validator(mode="after")
|
|
def _enforce_production_secrets(self) -> "Settings":
|
|
"""Fail fast rather than boot a public instance with the shipped placeholder
|
|
signing key (which would let anyone forge an admin session) or without token
|
|
encryption. Local dev (http redirect URL) keeps the convenient defaults."""
|
|
if not self.session_https_only:
|
|
return self # local/dev: allow the placeholder defaults
|
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < 32:
|
|
raise ValueError(
|
|
"SECRET_KEY must be a unique random value of at least 32 chars in "
|
|
"production (an https OAUTH_REDIRECT_URL was detected). Generate one "
|
|
'with: python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
|
)
|
|
if not self.token_encryption_key:
|
|
raise ValueError(
|
|
"TOKEN_ENCRYPTION_KEY must be set in production so refresh tokens are "
|
|
"encrypted at rest. Generate one with: "
|
|
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
|
)
|
|
return self
|
|
|
|
|
|
settings = Settings()
|