Skip to content

Fix: Broken Items #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions alembic/versions/002_add_api_key_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Add API Key table

Revision ID: 002_add_api_key_table
Revises: 001_initial_schema
Create Date: 2025-07-11 09:30:00.000000

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '002_add_api_key_table'
down_revision = '001_initial_schema'
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Create API keys table."""
# Create api_keys table
op.create_table(
'api_keys',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('key_hash', sa.String(length=64), nullable=False),
sa.Column('key_prefix', sa.String(length=8), nullable=False),
sa.Column('user_id', sa.String(length=255), nullable=True),
sa.Column('organization', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('is_admin', sa.Boolean(), nullable=False, default=False),
sa.Column('max_concurrent_jobs', sa.Integer(), nullable=False, default=5),
sa.Column('monthly_limit_minutes', sa.Integer(), nullable=False, default=10000),
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_by', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)

# Create indexes
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_key_prefix', 'api_keys', ['key_prefix'])
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
op.create_index('ix_api_keys_organization', 'api_keys', ['organization'])
op.create_index('ix_api_keys_is_active', 'api_keys', ['is_active'])
op.create_index('ix_api_keys_created_at', 'api_keys', ['created_at'])
op.create_index('ix_api_keys_expires_at', 'api_keys', ['expires_at'])

# Add composite index for common queries
op.create_index('ix_api_keys_active_lookup', 'api_keys', ['is_active', 'revoked_at', 'expires_at'])


def downgrade() -> None:
"""Drop API keys table."""
# Drop indexes
op.drop_index('ix_api_keys_active_lookup', table_name='api_keys')
op.drop_index('ix_api_keys_expires_at', table_name='api_keys')
op.drop_index('ix_api_keys_created_at', table_name='api_keys')
op.drop_index('ix_api_keys_is_active', table_name='api_keys')
op.drop_index('ix_api_keys_organization', table_name='api_keys')
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_prefix', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')

# Drop table
op.drop_table('api_keys')
80 changes: 67 additions & 13 deletions api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def get_api_key(
async def require_api_key(
request: Request,
api_key: Optional[str] = Depends(get_api_key),
db: AsyncSession = Depends(get_db),
) -> str:
"""Require valid API key for endpoint access."""
if not settings.ENABLE_API_KEYS:
Expand All @@ -48,44 +49,97 @@ async def require_api_key(
headers={"WWW-Authenticate": "Bearer"},
)

# In production, validate against database
# For now, accept any non-empty key
if not api_key.strip():
# Validate API key against database
from api.services.api_key import APIKeyService

api_key_model = await APIKeyService.validate_api_key(
db, api_key, update_usage=True
)

if not api_key_model:
logger.warning(
"Invalid API key attempted",
api_key_prefix=api_key[:8] + "..." if len(api_key) > 8 else api_key,
client_ip=request.client.host,
)
raise HTTPException(
status_code=401,
detail="Invalid API key",
)

# Check IP whitelist if enabled
if settings.ENABLE_IP_WHITELIST:
import ipaddress
client_ip = request.client.host
if not any(client_ip.startswith(ip) for ip in settings.ip_whitelist_parsed):

# Validate client IP against CIDR ranges
client_ip_obj = ipaddress.ip_address(client_ip)
allowed = False

for allowed_range in settings.ip_whitelist_parsed:
try:
if client_ip_obj in ipaddress.ip_network(allowed_range, strict=False):
allowed = True
break
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
# Fallback to string comparison for invalid CIDR
if client_ip.startswith(allowed_range):
allowed = True
break

if not allowed:
logger.warning(
"IP not in whitelist",
client_ip=client_ip,
api_key=api_key[:8] + "...",
api_key_id=str(api_key_model.id),
user_id=api_key_model.user_id,
)
raise HTTPException(
status_code=403,
detail="IP address not authorized",
)

# Store API key model in request state for other endpoints
request.state.api_key_model = api_key_model

return api_key


async def get_current_user(
request: Request,
api_key: str = Depends(require_api_key),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Get current user from API key."""
# In production, look up user from database
# For now, return mock user
"""Get current user from validated API key."""
# Get API key model from request state (set by require_api_key)
api_key_model = getattr(request.state, 'api_key_model', None)

if not api_key_model:
# Fallback for anonymous access
return {
"id": "anonymous",
"api_key": api_key,
"role": "anonymous",
"quota": {
"concurrent_jobs": 1,
"monthly_minutes": 100,
},
}

return {
"id": "user_123",
"id": api_key_model.user_id or f"api_key_{api_key_model.id}",
"api_key_id": str(api_key_model.id),
"api_key": api_key,
"role": "user",
"name": api_key_model.name,
"organization": api_key_model.organization,
"role": "admin" if api_key_model.is_admin else "user",
"quota": {
"concurrent_jobs": settings.MAX_CONCURRENT_JOBS_PER_KEY,
"monthly_minutes": 10000,
"concurrent_jobs": api_key_model.max_concurrent_jobs,
"monthly_minutes": api_key_model.monthly_limit_minutes,
},
"usage": {
"total_requests": api_key_model.total_requests,
"last_used_at": api_key_model.last_used_at.isoformat() if api_key_model.last_used_at else None,
},
"expires_at": api_key_model.expires_at.isoformat() if api_key_model.expires_at else None,
"is_admin": api_key_model.is_admin,
}
34 changes: 30 additions & 4 deletions api/genai/services/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,39 @@ async def _load_videomae_model(self, model_name: str, **kwargs) -> Any:
raise ImportError(f"VideoMAE dependencies not installed: {e}")

async def _load_vmaf_model(self, model_name: str, **kwargs) -> Any:
"""Load VMAF model."""
"""Load VMAF model configuration."""
try:
import ffmpeg
import os

# VMAF is handled by FFmpeg, so we just return a placeholder
# The actual VMAF computation will be done in the quality predictor
return {"model_version": model_name, "available": True}
# VMAF models are handled by FFmpeg, not loaded into memory
# We validate the model exists and return configuration
vmaf_models = {
"vmaf_v0.6.1": {"version": "v0.6.1", "path": "/usr/local/share/model/vmaf_v0.6.1.json"},
"vmaf_4k_v0.6.1": {"version": "v0.6.1_4k", "path": "/usr/local/share/model/vmaf_4k_v0.6.1.json"},
"vmaf_v0.6.0": {"version": "v0.6.0", "path": "/usr/local/share/model/vmaf_v0.6.0.json"},
}

model_config = vmaf_models.get(model_name)
if not model_config:
raise ValueError(f"Unknown VMAF model: {model_name}")

# Check if model file exists (optional, FFmpeg will handle missing models)
model_available = True
if model_config["path"] and os.path.exists(model_config["path"]):
model_available = True
elif model_config["path"]:
# Model file not found, but FFmpeg might have it in different location
logger.warning(f"VMAF model file not found at {model_config['path']}, will use FFmpeg default")

return {
"model_name": model_name,
"version": model_config["version"],
"path": model_config["path"],
"available": model_available,
"type": "vmaf",
"description": f"VMAF quality assessment model {model_config['version']}",
}

except ImportError as e:
raise ImportError(f"FFmpeg-python not installed: {e}")
Expand Down
3 changes: 2 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import structlog

from api.config import settings
from api.routers import convert, jobs, admin, health
from api.routers import convert, jobs, admin, health, api_keys
from api.utils.logger import setup_logging
from api.utils.error_handlers import (
RendiffError, rendiff_exception_handler, validation_exception_handler,
Expand Down Expand Up @@ -123,6 +123,7 @@ async def lifespan(app: FastAPI):
app.include_router(jobs.router, prefix="/api/v1", tags=["jobs"])
app.include_router(admin.router, prefix="/api/v1", tags=["admin"])
app.include_router(health.router, prefix="/api/v1", tags=["health"])
app.include_router(api_keys.router, prefix="/api/v1", tags=["api-keys"])

# Conditionally include GenAI routers
try:
Expand Down
5 changes: 5 additions & 0 deletions api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .database import Base, get_session
from .job import Job, JobStatus
from .api_key import APIKey

__all__ = ["Base", "get_session", "Job", "JobStatus", "APIKey"]
Loading
Loading