Replaces per-member Nextcloud credentials with a BandStorage model that supports multiple providers. Credentials are Fernet-encrypted at rest; worker receives audio via an internal streaming endpoint instead of direct storage access. - Add BandStorage DB model with partial unique index (one active per band) - Add migrations 0007 (create band_storage) and 0008 (drop old nc columns) - Add StorageFactory that builds the correct StorageClient from BandStorage - Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect - Add Fernet encryption helpers in security/encryption.py - Rewrite watcher for per-band polling via internal API config endpoint - Update worker to stream audio from API instead of accessing storage directly - Update frontend: new storage API in bands.ts, rewritten StorageSection, simplified band creation modal (no storage step) - Add STORAGE_ENCRYPTION_KEY to all docker-compose files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
"""Add band_storage table for provider-agnostic, encrypted storage configs.
|
|
|
|
Each band can have one active storage provider (Nextcloud, Google Drive, etc.).
|
|
Credentials are Fernet-encrypted at the application layer — never stored in plaintext.
|
|
A partial unique index enforces at most one active config per band at the DB level.
|
|
|
|
Revision ID: 0007_band_storage
|
|
Revises: 0006_waveform_peaks_in_db
|
|
Create Date: 2026-04-10
|
|
"""
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
|
|
revision = "0007_band_storage"
|
|
down_revision = "0006_waveform_peaks_in_db"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"band_storage",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"band_id",
|
|
UUID(as_uuid=True),
|
|
sa.ForeignKey("bands.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("provider", sa.String(20), nullable=False),
|
|
sa.Column("label", sa.String(255), nullable=True),
|
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default="false"),
|
|
sa.Column("root_path", sa.Text, nullable=True),
|
|
# Fernet-encrypted JSON — never plaintext
|
|
sa.Column("credentials", sa.Text, nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(),
|
|
nullable=False,
|
|
),
|
|
)
|
|
|
|
# Index for fast per-band lookups
|
|
op.create_index("ix_band_storage_band_id", "band_storage", ["band_id"])
|
|
|
|
# Partial unique index: at most one active storage per band
|
|
op.execute(
|
|
"""
|
|
CREATE UNIQUE INDEX uq_band_active_storage
|
|
ON band_storage (band_id)
|
|
WHERE is_active = true
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.execute("DROP INDEX IF EXISTS uq_band_active_storage")
|
|
op.drop_index("ix_band_storage_band_id", table_name="band_storage")
|
|
op.drop_table("band_storage")
|