Skip to content

Commit d200219

Browse files
committed
Fix: Broken Items
1 parent b1d4ceb commit d200219

File tree

10 files changed

+1338
-28
lines changed

10 files changed

+1338
-28
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Add API Key table
2+
3+
Revision ID: 002_add_api_key_table
4+
Revises: 001_initial_schema
5+
Create Date: 2025-07-11 09:30:00.000000
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '002_add_api_key_table'
14+
down_revision = '001_initial_schema'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
"""Create API keys table."""
21+
# Create api_keys table
22+
op.create_table(
23+
'api_keys',
24+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
25+
sa.Column('name', sa.String(length=255), nullable=False),
26+
sa.Column('key_hash', sa.String(length=64), nullable=False),
27+
sa.Column('key_prefix', sa.String(length=8), nullable=False),
28+
sa.Column('user_id', sa.String(length=255), nullable=True),
29+
sa.Column('organization', sa.String(length=255), nullable=True),
30+
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
31+
sa.Column('is_admin', sa.Boolean(), nullable=False, default=False),
32+
sa.Column('max_concurrent_jobs', sa.Integer(), nullable=False, default=5),
33+
sa.Column('monthly_limit_minutes', sa.Integer(), nullable=False, default=10000),
34+
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
35+
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
36+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
37+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
38+
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
39+
sa.Column('description', sa.Text(), nullable=True),
40+
sa.Column('created_by', sa.String(length=255), nullable=True),
41+
sa.PrimaryKeyConstraint('id')
42+
)
43+
44+
# Create indexes
45+
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
46+
op.create_index('ix_api_keys_key_prefix', 'api_keys', ['key_prefix'])
47+
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
48+
op.create_index('ix_api_keys_organization', 'api_keys', ['organization'])
49+
op.create_index('ix_api_keys_is_active', 'api_keys', ['is_active'])
50+
op.create_index('ix_api_keys_created_at', 'api_keys', ['created_at'])
51+
op.create_index('ix_api_keys_expires_at', 'api_keys', ['expires_at'])
52+
53+
# Add composite index for common queries
54+
op.create_index('ix_api_keys_active_lookup', 'api_keys', ['is_active', 'revoked_at', 'expires_at'])
55+
56+
57+
def downgrade() -> None:
58+
"""Drop API keys table."""
59+
# Drop indexes
60+
op.drop_index('ix_api_keys_active_lookup', table_name='api_keys')
61+
op.drop_index('ix_api_keys_expires_at', table_name='api_keys')
62+
op.drop_index('ix_api_keys_created_at', table_name='api_keys')
63+
op.drop_index('ix_api_keys_is_active', table_name='api_keys')
64+
op.drop_index('ix_api_keys_organization', table_name='api_keys')
65+
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
66+
op.drop_index('ix_api_keys_key_prefix', table_name='api_keys')
67+
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
68+
69+
# Drop table
70+
op.drop_table('api_keys')

api/dependencies.py

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async def get_api_key(
3636
async def require_api_key(
3737
request: Request,
3838
api_key: Optional[str] = Depends(get_api_key),
39+
db: AsyncSession = Depends(get_db),
3940
) -> str:
4041
"""Require valid API key for endpoint access."""
4142
if not settings.ENABLE_API_KEYS:
@@ -48,44 +49,97 @@ async def require_api_key(
4849
headers={"WWW-Authenticate": "Bearer"},
4950
)
5051

51-
# In production, validate against database
52-
# For now, accept any non-empty key
53-
if not api_key.strip():
52+
# Validate API key against database
53+
from api.services.api_key import APIKeyService
54+
55+
api_key_model = await APIKeyService.validate_api_key(
56+
db, api_key, update_usage=True
57+
)
58+
59+
if not api_key_model:
60+
logger.warning(
61+
"Invalid API key attempted",
62+
api_key_prefix=api_key[:8] + "..." if len(api_key) > 8 else api_key,
63+
client_ip=request.client.host,
64+
)
5465
raise HTTPException(
5566
status_code=401,
5667
detail="Invalid API key",
5768
)
5869

5970
# Check IP whitelist if enabled
6071
if settings.ENABLE_IP_WHITELIST:
72+
import ipaddress
6173
client_ip = request.client.host
62-
if not any(client_ip.startswith(ip) for ip in settings.ip_whitelist_parsed):
74+
75+
# Validate client IP against CIDR ranges
76+
client_ip_obj = ipaddress.ip_address(client_ip)
77+
allowed = False
78+
79+
for allowed_range in settings.ip_whitelist_parsed:
80+
try:
81+
if client_ip_obj in ipaddress.ip_network(allowed_range, strict=False):
82+
allowed = True
83+
break
84+
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
85+
# Fallback to string comparison for invalid CIDR
86+
if client_ip.startswith(allowed_range):
87+
allowed = True
88+
break
89+
90+
if not allowed:
6391
logger.warning(
6492
"IP not in whitelist",
6593
client_ip=client_ip,
66-
api_key=api_key[:8] + "...",
94+
api_key_id=str(api_key_model.id),
95+
user_id=api_key_model.user_id,
6796
)
6897
raise HTTPException(
6998
status_code=403,
7099
detail="IP address not authorized",
71100
)
72101

102+
# Store API key model in request state for other endpoints
103+
request.state.api_key_model = api_key_model
104+
73105
return api_key
74106

75107

76108
async def get_current_user(
109+
request: Request,
77110
api_key: str = Depends(require_api_key),
78-
db: AsyncSession = Depends(get_db),
79111
) -> dict:
80-
"""Get current user from API key."""
81-
# In production, look up user from database
82-
# For now, return mock user
112+
"""Get current user from validated API key."""
113+
# Get API key model from request state (set by require_api_key)
114+
api_key_model = getattr(request.state, 'api_key_model', None)
115+
116+
if not api_key_model:
117+
# Fallback for anonymous access
118+
return {
119+
"id": "anonymous",
120+
"api_key": api_key,
121+
"role": "anonymous",
122+
"quota": {
123+
"concurrent_jobs": 1,
124+
"monthly_minutes": 100,
125+
},
126+
}
127+
83128
return {
84-
"id": "user_123",
129+
"id": api_key_model.user_id or f"api_key_{api_key_model.id}",
130+
"api_key_id": str(api_key_model.id),
85131
"api_key": api_key,
86-
"role": "user",
132+
"name": api_key_model.name,
133+
"organization": api_key_model.organization,
134+
"role": "admin" if api_key_model.is_admin else "user",
87135
"quota": {
88-
"concurrent_jobs": settings.MAX_CONCURRENT_JOBS_PER_KEY,
89-
"monthly_minutes": 10000,
136+
"concurrent_jobs": api_key_model.max_concurrent_jobs,
137+
"monthly_minutes": api_key_model.monthly_limit_minutes,
138+
},
139+
"usage": {
140+
"total_requests": api_key_model.total_requests,
141+
"last_used_at": api_key_model.last_used_at.isoformat() if api_key_model.last_used_at else None,
90142
},
143+
"expires_at": api_key_model.expires_at.isoformat() if api_key_model.expires_at else None,
144+
"is_admin": api_key_model.is_admin,
91145
}

api/genai/services/model_manager.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,39 @@ async def _load_videomae_model(self, model_name: str, **kwargs) -> Any:
251251
raise ImportError(f"VideoMAE dependencies not installed: {e}")
252252

253253
async def _load_vmaf_model(self, model_name: str, **kwargs) -> Any:
254-
"""Load VMAF model."""
254+
"""Load VMAF model configuration."""
255255
try:
256256
import ffmpeg
257+
import os
257258

258-
# VMAF is handled by FFmpeg, so we just return a placeholder
259-
# The actual VMAF computation will be done in the quality predictor
260-
return {"model_version": model_name, "available": True}
259+
# VMAF models are handled by FFmpeg, not loaded into memory
260+
# We validate the model exists and return configuration
261+
vmaf_models = {
262+
"vmaf_v0.6.1": {"version": "v0.6.1", "path": "/usr/local/share/model/vmaf_v0.6.1.json"},
263+
"vmaf_4k_v0.6.1": {"version": "v0.6.1_4k", "path": "/usr/local/share/model/vmaf_4k_v0.6.1.json"},
264+
"vmaf_v0.6.0": {"version": "v0.6.0", "path": "/usr/local/share/model/vmaf_v0.6.0.json"},
265+
}
266+
267+
model_config = vmaf_models.get(model_name)
268+
if not model_config:
269+
raise ValueError(f"Unknown VMAF model: {model_name}")
270+
271+
# Check if model file exists (optional, FFmpeg will handle missing models)
272+
model_available = True
273+
if model_config["path"] and os.path.exists(model_config["path"]):
274+
model_available = True
275+
elif model_config["path"]:
276+
# Model file not found, but FFmpeg might have it in different location
277+
logger.warning(f"VMAF model file not found at {model_config['path']}, will use FFmpeg default")
278+
279+
return {
280+
"model_name": model_name,
281+
"version": model_config["version"],
282+
"path": model_config["path"],
283+
"available": model_available,
284+
"type": "vmaf",
285+
"description": f"VMAF quality assessment model {model_config['version']}",
286+
}
261287

262288
except ImportError as e:
263289
raise ImportError(f"FFmpeg-python not installed: {e}")

api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import structlog
1414

1515
from api.config import settings
16-
from api.routers import convert, jobs, admin, health
16+
from api.routers import convert, jobs, admin, health, api_keys
1717
from api.utils.logger import setup_logging
1818
from api.utils.error_handlers import (
1919
RendiffError, rendiff_exception_handler, validation_exception_handler,
@@ -123,6 +123,7 @@ async def lifespan(app: FastAPI):
123123
app.include_router(jobs.router, prefix="/api/v1", tags=["jobs"])
124124
app.include_router(admin.router, prefix="/api/v1", tags=["admin"])
125125
app.include_router(health.router, prefix="/api/v1", tags=["health"])
126+
app.include_router(api_keys.router, prefix="/api/v1", tags=["api-keys"])
126127

127128
# Conditionally include GenAI routers
128129
try:

api/models/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .database import Base, get_session
2+
from .job import Job, JobStatus
3+
from .api_key import APIKey
4+
5+
__all__ = ["Base", "get_session", "Job", "JobStatus", "APIKey"]

0 commit comments

Comments
 (0)