- Add INTERNAL_SECRET shared-secret auth to /internal/nc-upload endpoint
- Add JWT token validation to WebSocket /ws/versions/{version_id}
- Fix NameError: band_slug → band.slug in internal.py
- Move inline imports to top of internal.py; add missing Member/NextcloudClient imports
- Remove ~15 debug print() statements from auth.py
- Replace Content-Type-only avatar check with extension whitelist + Pillow Image.verify()
- Sanitize exception details in versions.py (no more str(e) in 4xx/5xx responses)
- Restrict CORS allow_methods/allow_headers from "*" to explicit lists
- Add security headers middleware: X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- Reduce JWT expiry from 7 days to 1 hour
- Add Pillow>=10.0 dependency; document INTERNAL_SECRET in .env.example
- Implement missing RedisJobQueue.dequeue() method (required by protocol)
- Fix 5 pre-existing unit test failures: settings env vars conftest, deferred Redis push,
dequeue method, AsyncMock→MagicMock for sync scalar_one_or_none
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
6.1 KiB
Python
151 lines
6.1 KiB
Python
"""Internal endpoints — called by trusted services (watcher) on the Docker network."""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from rehearsalhub.config import get_settings
|
|
from rehearsalhub.db.engine import get_session
|
|
from rehearsalhub.db.models import BandMember, Member
|
|
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
|
from rehearsalhub.repositories.band import BandRepository
|
|
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
|
|
from rehearsalhub.repositories.song import SongRepository
|
|
from rehearsalhub.schemas.audio_version import AudioVersionCreate
|
|
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
|
|
from rehearsalhub.services.song import SongService
|
|
from rehearsalhub.storage.nextcloud import NextcloudClient
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/internal", tags=["internal"])
|
|
|
|
|
|
async def _verify_internal_secret(x_internal_token: str | None = Header(None)) -> None:
|
|
"""Verify the shared secret sent by internal services."""
|
|
settings = get_settings()
|
|
if x_internal_token != settings.internal_secret:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
|
|
|
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
|
|
|
|
|
|
class NcUploadEvent(BaseModel):
|
|
nc_file_path: str
|
|
nc_file_etag: str | None = None
|
|
|
|
|
|
@router.post("/nc-upload", status_code=200)
|
|
async def nc_upload(
|
|
event: NcUploadEvent,
|
|
session: AsyncSession = Depends(get_session),
|
|
_: None = Depends(_verify_internal_secret),
|
|
):
|
|
"""
|
|
Called by nc-watcher when a new audio file is detected in Nextcloud.
|
|
Parses the path to find/create the band+song and registers a version.
|
|
|
|
Expected path format: bands/{slug}/[songs/]{folder}/filename.ext
|
|
"""
|
|
path = event.nc_file_path.lstrip("/")
|
|
|
|
if Path(path).suffix.lower() not in AUDIO_EXTENSIONS:
|
|
return {"status": "skipped", "reason": "not an audio file"}
|
|
|
|
band_repo = BandRepository(session)
|
|
|
|
# Try slug-based lookup first (standard bands/{slug}/ layout)
|
|
parts = path.split("/")
|
|
band = None
|
|
if len(parts) >= 3 and parts[0] == "bands":
|
|
band = await band_repo.get_by_slug(parts[1])
|
|
|
|
# Fall back to prefix match for bands with custom nc_folder_path
|
|
if band is None:
|
|
band = await band_repo.get_by_nc_folder_prefix(path)
|
|
|
|
if band is None:
|
|
log.info("nc-upload: no band found for path '%s' — skipping", path)
|
|
return {"status": "skipped", "reason": "band not found"}
|
|
|
|
# Determine song title and folder from path.
|
|
# The title is always the filename stem (e.g. "take1" from "take1.wav").
|
|
# The nc_folder groups all versions of the same recording (the parent directory).
|
|
#
|
|
# Examples:
|
|
# bands/my-band/take1.wav → folder=bands/my-band/, title=take1
|
|
# bands/my-band/231015/take1.wav → folder=bands/my-band/231015/, title=take1
|
|
# bands/my-band/songs/groove/take1.wav → folder=bands/my-band/songs/groove/, title=take1
|
|
parent = str(Path(path).parent)
|
|
nc_folder = parent.rstrip("/") + "/"
|
|
title = Path(path).stem
|
|
|
|
# If the file sits directly inside a dated session folder, give it a unique
|
|
# virtual folder so it becomes its own song (not merged with other takes).
|
|
session_folder_path = extract_session_folder(path)
|
|
if session_folder_path and session_folder_path.rstrip("/") == nc_folder.rstrip("/"):
|
|
nc_folder = nc_folder + title + "/"
|
|
|
|
version_repo = AudioVersionRepository(session)
|
|
if event.nc_file_etag and await version_repo.get_by_etag(event.nc_file_etag):
|
|
return {"status": "skipped", "reason": "version already registered"}
|
|
|
|
# Resolve or create rehearsal session from YYMMDD folder segment
|
|
session_repo = RehearsalSessionRepository(session)
|
|
rehearsal_date = parse_rehearsal_date(path)
|
|
rehearsal_session_id = None
|
|
if rehearsal_date:
|
|
rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder)
|
|
rehearsal_session_id = rehearsal_session.id
|
|
log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date)
|
|
|
|
song_repo = SongRepository(session)
|
|
song = await song_repo.get_by_nc_folder_path(nc_folder)
|
|
if song is None:
|
|
song = await song_repo.get_by_title_and_band(band.id, title)
|
|
if song is None:
|
|
song = await song_repo.create(
|
|
band_id=band.id,
|
|
session_id=rehearsal_session_id,
|
|
title=title,
|
|
status="jam",
|
|
notes=None,
|
|
nc_folder_path=nc_folder,
|
|
created_by=None,
|
|
)
|
|
log.info("nc-upload: created song '%s' for band '%s'", title, band.slug)
|
|
elif rehearsal_session_id and song.session_id is None:
|
|
song = await song_repo.update(song, session_id=rehearsal_session_id)
|
|
|
|
# Use first member of the band as uploader (best-effort for watcher uploads)
|
|
result = await session.execute(
|
|
select(BandMember.member_id).where(BandMember.band_id == band.id).limit(1)
|
|
)
|
|
uploader_id = result.scalar_one_or_none()
|
|
|
|
# Get the uploader's storage credentials
|
|
storage = None
|
|
if uploader_id:
|
|
uploader_result = await session.execute(
|
|
select(Member).where(Member.id == uploader_id).limit(1) # type: ignore[arg-type]
|
|
)
|
|
uploader = uploader_result.scalar_one_or_none()
|
|
storage = NextcloudClient.for_member(uploader) if uploader else None
|
|
|
|
song_svc = SongService(session, storage=storage)
|
|
version = await song_svc.register_version(
|
|
song.id,
|
|
AudioVersionCreate(
|
|
nc_file_path=path,
|
|
nc_file_etag=event.nc_file_etag,
|
|
format=Path(path).suffix.lstrip(".").lower(),
|
|
),
|
|
uploader_id,
|
|
)
|
|
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title)
|
|
return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}
|