"""Share-link tokens no longer stored in the clear Revision ID: 0107_link_token_at_rest Revises: 0106_api_tokens Create Date: 2026-10-15 A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written, so a copy of the database was every shared file. Now a link is found by the token's SHA-256 (`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its owner can copy the same link again. Without a key the copy is not kept: the link still opens, its address just cannot be shown again (`app/downloads/links.py`). Every existing link is converted here, so the addresses already sent keep working. The downgrade writes the tokens back from their encrypted copies; a link with none left (no key, or a key since changed) gets a fresh random token, which is the same as revoking it. """ import hashlib import secrets import sqlalchemy as sa from alembic import op from app import security revision: str = "0107_link_token_at_rest" down_revision: str | None = "0106_api_tokens" branch_labels: str | None = None depends_on: str | None = None def upgrade() -> None: op.add_column("download_links", sa.Column("token_hash", sa.String(64))) op.add_column("download_links", sa.Column("token_enc", sa.Text())) conn = op.get_bind() sealing = security.can_encrypt() for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall(): conn.execute( sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"), { "id": link_id, "h": hashlib.sha256(token.encode()).hexdigest(), "e": security.encrypt(token) if sealing else None, }, ) op.alter_column("download_links", "token_hash", nullable=False) op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"]) op.drop_constraint("uq_download_link_token", "download_links", type_="unique") op.drop_column("download_links", "token") def downgrade() -> None: op.add_column("download_links", sa.Column("token", sa.String(64))) conn = op.get_bind() rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall() for link_id, enc in rows: token = security.decrypt_optional(enc) or secrets.token_urlsafe(24) conn.execute( sa.text("UPDATE download_links SET token = :t WHERE id = :id"), {"id": link_id, "t": token}, ) op.alter_column("download_links", "token", nullable=False) op.create_unique_constraint("uq_download_link_token", "download_links", ["token"]) op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique") op.drop_column("download_links", "token_enc") op.drop_column("download_links", "token_hash")