Save and retrieve playlists and epg guides from vercel blob storage

This commit is contained in:
2025-05-06 13:27:14 -05:00
parent a55695865e
commit beb1bdd8c0
8 changed files with 230 additions and 31 deletions

View File

@@ -5,6 +5,7 @@
"ASGI",
"Dockerized",
"dotenv",
"epgs",
"fastapi",
"iptv",
"newuser",
@@ -14,6 +15,7 @@
"securepassword",
"superadmin",
"uvicorn",
"venv"
"venv",
"vercel"
]
}

View File

@@ -9,5 +9,6 @@ dependencies = [
"python-dotenv>=1.0.0",
"python-multipart>=0.0.9",
"upstash-redis>=1.0.0",
"redis>=5.0.0"
"redis>=5.0.0",
"vercel_blob>=0.3.2"
]

View File

@@ -6,3 +6,4 @@ python-dotenv>=1.0.0
python-multipart>=0.0.9
upstash-redis>=1.0.0
redis>=5.0.0
vercel_blob>=0.3.2

View File

@@ -15,6 +15,9 @@ LOCAL_REDIS_URL = os.getenv('LOCAL_REDIS_URL', 'redis://localhost:6379')
REDIS_URL = os.getenv("KV_REST_API_URL")
REDIS_TOKEN = os.getenv("KV_REST_API_TOKEN")
# Vercel Blob configuration
BLOB_READ_WRITE_TOKEN = os.getenv("BLOB_READ_WRITE_TOKEN")
# Define the directory where content files are stored
# Reads from environment variable CONTENT_PATH or defaults to '/content'
CONTENT_PATH = os.getenv("CONTENT_PATH", "/content")

View File

@@ -1,14 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
import re
import vercel_blob
from pathlib import PurePath
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from passlib.context import CryptContext
from src.utils.auth import verify_superadmin, get_user
from src.utils.database import redis
from src.models.models import User
from src.utils.admin import delete_blob_file, upload_blob_file
# Assuming pwd_context is initialized in auth.py and used there
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Re-initialize or import
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/register")
async def register_user(
user: User,
@@ -21,3 +26,52 @@ async def register_user(
hashed_password = pwd_context.hash(user.password)
redis.set(f"user:{user.username}", hashed_password)
return {"status": "User created", "username": user.username}
@router.delete("/users/{username}")
async def delete_user(
username: str,
_: bool = Depends(verify_superadmin)
):
existing_user = get_user(username)
if not existing_user:
raise HTTPException(status_code=404, detail="User not found")
if not redis.delete(f"user:{username}"):
raise HTTPException(
status_code=500,
detail="Failed to delete user from database"
)
return {"status": "User deleted", "username": username}
@router.post("/playlist")
async def upload_playlist(
file: UploadFile = File(...),
_: bool = Depends(verify_superadmin)
):
"""Upload a playlist file to Vercel Blob storage"""
return await upload_blob_file('playlists', file)
@router.delete("/playlist/{filename}")
async def delete_playlist(
filename: str,
_: bool = Depends(verify_superadmin)
):
"""Delete a playlist file from Vercel Blob storage"""
return delete_blob_file('playlists', filename)
@router.post("/epg")
async def upload_epg(
file: UploadFile = File(...),
_: bool = Depends(verify_superadmin)
):
"""Upload an EPG file to Vercel Blob storage"""
return await upload_blob_file('epgs', file)
@router.delete("/epg/{filename}")
async def delete_epg(
filename: str,
_: bool = Depends(verify_superadmin)
):
"""Delete an EPG file from Vercel Blob storage"""
return delete_blob_file('epgs', filename)

View File

@@ -1,33 +1,24 @@
from fastapi import APIRouter, Depends, Path, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path as FilePath
from src.config import CONTENT_PATH
from utils.database import get_blob_by_path
from fastapi import APIRouter, Depends, Path
from fastapi.responses import RedirectResponse
from src.utils.auth import authenticate_user_query
router = APIRouter(prefix="/content", tags=["content"])
@router.get("/{filename}")
@router.get("/playlist/{filename}")
async def serve_content(
username: str = Depends(authenticate_user_query),
filename: str = Path(..., min_length=1), # Ensure filename is not empty
filename: str = Path(..., min_length=1),
):
"""
Serves a specific file from the configured content directory by filename only.
Prevents directory traversal and ensures files are served from the base content directory.
"""
base_dir = FilePath(CONTENT_PATH).resolve()
requested_file = base_dir / filename
print(f"Requested file: {requested_file}")
# Security checks
try:
# Resolve the full path and check it's within base directory
requested_file = requested_file.resolve()
if not requested_file.is_relative_to(base_dir):
raise ValueError("Invalid path")
except (ValueError, FileNotFoundError):
raise HTTPException(status_code=404, detail="File not found")
"""Redirect to the Vercel Blob URL for the requested playlist file"""
matching_blob = get_blob_by_path('playlists', filename)
return RedirectResponse(url=matching_blob['url'])
if not requested_file.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(requested_file)
@router.get("/epg/{filename}")
async def serve_content(
username: str = Depends(authenticate_user_query),
filename: str = Path(..., min_length=1),
):
"""Redirect to the Vercel Blob URL for the requested playlist file"""
matching_blob = get_blob_by_path('epgs', filename)
return RedirectResponse(url=matching_blob['url'])

129
src/utils/admin.py Normal file
View File

@@ -0,0 +1,129 @@
import re
from pathlib import PurePath
from fastapi import HTTPException, UploadFile
import vercel_blob
from .database import get_blob_by_path
import logging
# Configure logging
logger = logging.getLogger(__name__)
ALLOWED_EXTENSIONS = {
'playlists': ('.m3u', '.m3u8'),
'epgs': ('.xml', '.xml.gz')
}
async def upload_blob_file(folder: str, file: UploadFile) -> dict:
"""
Upload a file to Vercel Blob storage with validation
Args:
folder (str): The folder name (e.g. 'playlists' or 'epgs')
file (UploadFile): The file to upload
Returns:
dict: Information about the uploaded blob
Raises:
HTTPException: If validation fails or upload fails
"""
try:
# Validate filename
if not file.filename or not file.filename.strip():
raise HTTPException(status_code=400, detail="Invalid filename provided")
# Sanitize filename
sanitized_filename = PurePath(file.filename).name
sanitized_filename = re.sub(r'[^a-zA-Z0-9_.-]', '_', sanitized_filename)
# Validate file extension
if not sanitized_filename.lower().endswith(ALLOWED_EXTENSIONS[folder]):
raise HTTPException(
status_code=400,
detail=f"Only {', '.join(ALLOWED_EXTENSIONS[folder])} files are allowed"
)
content = await file.read()
# Validate content size (10MB limit)
max_size = 10 * 1024 * 1024 # 10MB
if len(content) > max_size:
raise HTTPException(
status_code=413,
detail=f"File size exceeds {max_size//1024//1024}MB limit"
)
blob_info = vercel_blob.put(
path=f"{folder}/{sanitized_filename}",
data=content,
options={
"contentType": file.content_type or "application/octet-stream",
"access": 'public',
"addRandomSuffix": False
}
)
return {
"status": "uploaded",
"filename": sanitized_filename,
"url": blob_info['url'],
"size": len(content),
"content_type": blob_info.get('contentType')
}
except KeyError as e:
logger.error(f"Unexpected blob storage response: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Unexpected response format from blob storage: {str(e)}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"File upload failed: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"File upload failed: {str(e)}"
)
def delete_blob_file(folder: str, filename: str) -> dict:
"""
Delete a file from Vercel Blob storage
Args:
folder (str): The folder name (e.g. 'playlists' or 'epgs')
filename (str): The filename to delete
Returns:
dict: Information about the deleted blob
Raises:
HTTPException: If the file is not found or deletion fails
"""
try:
# Sanitize filename for safety
sanitized_filename = PurePath(filename).name
sanitized_filename = re.sub(r'[^a-zA-Z0-9_.-]', '_', sanitized_filename)
# Get the blob information first to get the URL
blob_info = get_blob_by_path(folder, sanitized_filename)
# Delete using the blob URL
vercel_blob.delete(blob_info['url'])
return {
"status": "deleted",
"filename": sanitized_filename,
"url": blob_info['url']
}
except HTTPException as he:
# Re-raise HTTP exceptions (like 404 from get_blob_by_path)
logger.error(f"HTTP error while deleting blob: {str(he)}")
raise he
except Exception as e:
logger.error(f"Failed to delete blob: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to delete file: {str(e)}"
)

View File

@@ -1,5 +1,6 @@
from upstash_redis import Redis as UpstashRedis
from redis import Redis as LocalRedis, RedisError
import vercel_blob
from src.config import REDIS_URL, REDIS_TOKEN, USE_LOCAL_REDIS, LOCAL_REDIS_URL
from fastapi import HTTPException
import logging
@@ -34,3 +35,20 @@ except RedisError as re:
except Exception as e:
logger.error(f"Unexpected error while connecting to Redis: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error")
def get_blob_by_path(folder: str, filename: str) -> dict:
# Get list of all blobs
resp = vercel_blob.list()
# Search for matching blob path
target_path = f"{folder}/{filename}"
matching_blob = next(
(blob for blob in resp.get('blobs', [])
if blob.get('pathname') == target_path),
None
)
if not matching_blob:
raise HTTPException(status_code=404, detail="File not found")
return matching_blob