from datetime import date, datetime from typing import TYPE_CHECKING from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, Date, DateTime, ForeignKey, Index, Integer, JSON, LargeBinary, SmallInteger, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from app.db import Base from app.runstatus import RunStatus, RunTrigger, sql_in from app.utils import canonical_email if TYPE_CHECKING: from app.catalog.models import Subscription # Every user-supplied display string in this schema shares one width, and it lives HERE so the # columns below are declared with it rather than with a literal: a name a person typed goes into # `User.display_name` (their Google profile name), `DownloadJob.display_name` and # `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which # `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted. DISPLAY_TEXT_MAX = 255 def display_text(raw: str | None) -> str | None: """A user-supplied display string on its way into one of those columns: stripped, truncated, blank -> None. One definition because there are SIX writers and they had drifted: `POST /api/downloads`, `POST /api/downloads/edit`, `POST /api/files/upload`, the library PATCH's two fields, and the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather than refusing: the name is a label on work the user has already done — a file already transferred, an account already authenticated — and losing its tail is not worth failing the request over. `test_upload.py` holds both halves: that every route assigning one of these fields goes through this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this ended up being written twice. """ return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None # The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint # routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500. POSITION_MAX_S = 2**31 - 1 class TimestampMixin: """Adds a server-defaulted `created_at`. Models that index created_at or order it differently (QuotaEvent, Notification) keep their own column on purpose.""" created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) class CanonicalEmailMixin: """Keeps `email_canonical` derived from `email` on every write. The invariant — "identity is matched on the canonical form, mail is sent to the typed one" — lives HERE, in code, instead of in a comment every write site has to remember. That distinction is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an approved user's Google sign-in silently became a second access request. """ @validates("email") def _derive_email_canonical(self, _key: str, value: str | None) -> str | None: self.email_canonical = canonical_email(value) return value class UpdatedAtMixin: """Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE.""" updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) class User(Base, TimestampMixin, CanonicalEmailMixin): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) # NULL for an email+password account that hasn't linked Google yet (Postgres allows # multiple NULLs under a unique index). Set on Google sign-in / in-app linking. google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) # The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and # `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately # NOT unique: prod already carries two rows for one mailbox (a password registration and the # Google sign-in that failed to find it), and a migration must not merge accounts on its own. email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") # The address Google asserted for this account, recorded at sign-in / link time. NOT the same # thing as `email`: a Google sign-in no longer overwrites the address the user # registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other # half of the truth lives, for the UI to show when the two differ. NULL until the next Google # sign-in for accounts linked before this column existed. google_email: Mapped[str | None] = mapped_column(String(320)) # argon2id hash for email+password login; NULL for Google-only accounts. password_hash: Mapped[str | None] = mapped_column(String(255)) # When SEC-10 dropped a preset password on Google adoption (see the single caller of # auth.revoke_untrusted_preset_password; admin approval no longer revokes). Without # this trace a revoked account was indistinguishable from a Google-only one, so it failed the # password-reset eligibility check and could never be recovered — it could neither sign in nor # reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a # password is set again (reset-confirm / set-password). password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Email ownership proven (Google sign-in is implicitly verified; password registration # verifies via a link). Login requires this AND is_active. email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # Admin-approved & enabled. A pending password registration starts inactive; admin # approval activates it. A deactivated account can't log in. is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") # Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended # account can't sign in by any method and its live sessions are rejected; the user is told # why and pointed at the operator's contact. The worker leaves its queued downloads unclaimed # until the block is lifted (`worker._claim_job`). is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password # change, or "log out everywhere". A signed session cookie records the epoch it was minted under; # current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those # events (client-side signed cookies otherwise stay valid until they expire). session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # When this account last signed in (Google or password), stamped by the one sign-in path, # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is # possible). last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The diagnostics level this user consented to and when it lapses back to the base # level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry # itself; read through `app.diagnostics.effective_level`, never directly (an expired level is # not cleared eagerly — it simply stops counting). diagnostics_level: Mapped[str | None] = mapped_column(String(8)) diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and # cached. Used to exclude their own channel from playlist discovery. NULL = unresolved. yt_channel_id: Mapped[str | None] = mapped_column(String(32)) # Free-form UI preferences (theme, color scheme, font scale, default filters…). preferences: Mapped[dict | None] = mapped_column(JSON) token: Mapped["OAuthToken | None"] = relationship( back_populates="user", uselist=False, cascade="all, delete-orphan" ) subscriptions: Mapped[list["Subscription"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class OAuthToken(Base, UpdatedAtMixin): __tablename__ = "oauth_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True) # Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk. refresh_token_enc: Mapped[str | None] = mapped_column(String) access_token: Mapped[str | None] = mapped_column(String) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scopes: Mapped[str | None] = mapped_column(String) user: Mapped["User"] = relationship(back_populates="token") class AuthToken(Base, TimestampMixin): """Single-use, expiring token for email verification or password reset. Only the SHA-256 HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token lives only in the emailed link. Consumed (used_at set) on first valid use.""" __tablename__ = "auth_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset" token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class Invite(Base, CanonicalEmailMixin): """Access-request / whitelist row. Source of truth for who may sign in; the env ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed).""" __tablename__ = "invites" id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) # See User.email_canonical. Not unique here either: prod has approved invites for both spellings # of the same mailbox, dating back to 2026-06-16. email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") # pending | approved | denied status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending") note: Mapped[str | None] = mapped_column(Text) requested_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email # When the admin last SENT this person an invitation from the whitelist row. Adding an address # stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate # act, and this is what stops it happening twice to a real person. invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The language this person asked us in ("en"/"hu"), captured when the request was made: the page # their form was on, or the locale Google reported for a refused sign-in. NULL for a row the # admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly # why the invitation send asks. Captured rather than derived for the same reason # `preferences.language` and `google_email` are: the signal exists only in that one moment and # cannot be backfilled afterwards. lang: Mapped[str | None] = mapped_column(String(8)) class ApiQuotaUsage(Base): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" __tablename__ = "api_quota_usage" day: Mapped[date] = mapped_column(Date, primary_key=True) units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0") class QuotaEvent(Base): """Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user whose action caused the spend; NULL means background/system work (scheduler backfill, enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail.""" __tablename__ = "quota_events" id: Mapped[int] = mapped_column(primary_key=True) # SET NULL on user delete: keep the audit trail (it just becomes system-attributed). user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) action: Mapped[str] = mapped_column(String(40), index=True) units: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) # The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a # reaped run leaves the spend in the budget's history. job_run_id: Mapped[int | None] = mapped_column( ForeignKey("job_runs.id", ondelete="SET NULL"), index=True ) class AuditLog(Base): """Append-only admin audit trail: who did what, when, and (where it applies) the before->after values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user delete keeps the trail). Logged per ACTION, never per row; a background run is not an action and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets excluded). `request_id` is the request that made the change, so its log lines are one click away (NULL for rows written outside a request, and for rows older than the column).""" __tablename__ = "audit_log" id: Mapped[int] = mapped_column(primary_key=True) actor_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) action: Mapped[str] = mapped_column(String(48), index=True) target_type: Mapped[str | None] = mapped_column(String(32)) target_id: Mapped[str | None] = mapped_column(String(128)) summary: Mapped[str | None] = mapped_column(String(255)) before: Mapped[dict | None] = mapped_column(JSON) after: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) request_id: Mapped[str | None] = mapped_column(String(64), index=True) class LogEvent(Base): """One stored log event — the `log_events` table the diagnostics console reads. Rows are written by app/logsink.py's background writer, never inside a request's transaction, already redacted (the same `logs.record_fields` the stdout line is built from). `user_id` has NO foreign key on purpose: the writer runs behind the event, so a line about an account can arrive after that account was purged, and a constraint would make it fail the whole batch it travels in. `auth.purge_user` deletes an account's rows explicitly instead, after `logs.forget_user` makes every later write in this process store such a line without the account; the retention job (`log_retention_days`) bounds whatever another process writes late.""" __tablename__ = "log_events" __table_args__ = ( Index("ix_log_events_user_ts", "user_id", "ts"), # An issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. Index( "ix_log_events_debug_session", "debug_session_id", postgresql_where="debug_session_id IS NOT NULL", ), # A run's lines are `WHERE run_id = …`; request lines carry none. Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) level: Mapped[str] = mapped_column(String(10)) sev: Mapped[int] = mapped_column(SmallInteger) source: Mapped[str] = mapped_column(String(16)) logger: Mapped[str] = mapped_column(String(128)) event: Mapped[str | None] = mapped_column(String(64)) msg: Mapped[str] = mapped_column(Text) attrs: Mapped[dict | None] = mapped_column(JSONB) exc: Mapped[str | None] = mapped_column(Text) request_id: Mapped[str | None] = mapped_column(String(64), index=True) user_id: Mapped[int | None] = mapped_column(Integer) debug_session_id: Mapped[str | None] = mapped_column(String(64)) # A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`. job_id: Mapped[int | None] = mapped_column(Integer) # The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs # behind the event, and a run reaped meanwhile must not fail the batch its lines travel in. run_id: Mapped[int | None] = mapped_column(BigInteger) version: Mapped[str | None] = mapped_column(String(64)) class DebugSession(Base): """One recording: Start → reproduce → Stop (app/debugsession.py). While it is open, every request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`, and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the server set at Start; an open session past it is over whether or not the sweep has closed it yet. One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`).""" __tablename__ = "debug_sessions" __table_args__ = ( Index( "uq_debug_sessions_open_per_user", "user_id", unique=True, postgresql_where="ended_at IS NULL", ), ) id: Mapped[str] = mapped_column(String(16), primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) level: Mapped[str] = mapped_column(String(8)) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) until: Mapped[datetime] = mapped_column(DateTime(timezone=True)) ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # app/debugsession.py `EndReason`: stopped by the user, or past `until`. ended_reason: Mapped[str | None] = mapped_column(String(16)) issue_url: Mapped[str | None] = mapped_column(String(512)) # The issue being written from it (app/debugsession.py `save_draft` holds its shape). draft: Mapped[dict | None] = mapped_column(JSONB) class DebugScreenshot(Base): """A screenshot attached to a debug session — pasted, dropped, or captured from the pill. Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade.""" __tablename__ = "debug_screenshots" id: Mapped[int] = mapped_column(primary_key=True) session_id: Mapped[str] = mapped_column( ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) mime: Mapped[str] = mapped_column(String(16)) size: Mapped[int] = mapped_column(Integer) data: Mapped[bytes] = mapped_column(LargeBinary) class AppState(Base): """Single-row global app state (admin-controlled).""" __tablename__ = "app_state" id: Mapped[int] = mapped_column(primary_key=True, default=1) sync_paused: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # First-run install wizard. `configured` is False on a fresh instance (the app runs in # setup mode: only the wizard works) and flipped True when the wizard finishes. While # unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard; # the raw token + URL are printed to the container logs on each startup. configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") setup_token_hash: Mapped[str | None] = mapped_column(String(64)) class JobRun(Base): """One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one target — written when it starts and again when it ends (app/jobruns.py), each in its own committed session, so the history survives the job's own rollback and a restart. A row left `running` by a process that died is set `interrupted` at the next startup. `quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the same outcome as data, which the client names in the reader's language.""" __tablename__ = "job_runs" __table_args__ = ( Index("ix_job_runs_job_started", "job", "started_at"), CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"), CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"), # Only a run still going has no end. CheckConstraint( f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished" ), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) job: Mapped[str] = mapped_column(String(40)) trigger: Mapped[str] = mapped_column(String(16)) # Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule. actor_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) # What an ad-hoc run worked on (a channel id); NULL for the recurring jobs. target: Mapped[str | None] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16)) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) summary: Mapped[str | None] = mapped_column(Text) result: Mapped[dict | None] = mapped_column(JSONB) error: Mapped[str | None] = mapped_column(Text) failure: Mapped[dict | None] = mapped_column(JSONB) quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0") class SystemConfig(Base): """Admin-set overrides for operational config that otherwise comes from env/config defaults. Generic key/value store (one row per key); absent = use the env/config default. DB-backed so it's editable from the admin Configuration page at runtime without a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted` flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their types/groups/defaults lives in app.sysconfig (the single source of truth).""" __tablename__ = "system_config" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str | None] = mapped_column(Text) encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=func.now(), onupdate=func.now() ) class Notification(Base): """A durable, server-backed per-user notification (the inbox center, phase 1). Distinct from the client-side transient "bell" (toasts kept only in localStorage): these survive reloads and devices, and are produced server-side (first producer = the maintenance/validation job's batched "N saved/playlisted videos removed" notice). The column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves): a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read` and `dismissed` are separate: read clears the unread badge but keeps the row in the inbox; dismissed removes it from the default view. Unread rows never auto-expire; read ones are trimmed past a soft per-user cap (see app.notifications.trim_read).""" __tablename__ = "notifications" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance" title: Mapped[str] = mapped_column(String(255)) body: Mapped[str | None] = mapped_column(Text) data: Mapped[dict | None] = mapped_column(JSON) read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True) dismissed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class ApiToken(Base, TimestampMixin): """A user's token for a machine to reach their Files library with (`app.apitokens`): sent as `Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus its first characters (`prefix`), so the user can tell their tokens apart.""" __tablename__ = "api_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) name: Mapped[str] = mapped_column(String(80)) prefix: Mapped[str] = mapped_column(String(16)) token_hash: Mapped[str] = mapped_column(String(64), unique=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The one notice its user gets before it runs out (`apitokens.warn_expiring`). expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") class ApiTokenGrant(Base): """What one token may do in one folder. A folder with no grant is out of the token's reach.""" __tablename__ = "api_token_grants" __table_args__ = ( UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"), CheckConstraint( "can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some" ), ) id: Mapped[int] = mapped_column(primary_key=True) token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE")) folder_id: Mapped[int] = mapped_column( ForeignKey("file_folders.id", ondelete="CASCADE"), index=True ) can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")