diff --git a/.vscode/settings.json b/.vscode/settings.json index 3925d62..2c53add 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "ASGI", "Dockerized", "dotenv", + "epgs", "fastapi", "iptv", "newuser", @@ -14,6 +15,7 @@ "securepassword", "superadmin", "uvicorn", - "venv" + "venv", + "vercel" ] } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6a047c8..f39cfcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f36e4bc..0983c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ bcrypt<4.0.0 python-dotenv>=1.0.0 python-multipart>=0.0.9 upstash-redis>=1.0.0 -redis>=5.0.0 \ No newline at end of file +redis>=5.0.0 +vercel_blob>=0.3.2 \ No newline at end of file diff --git a/src/config.py b/src/config.py index 842edd2..59d7fc7 100644 --- a/src/config.py +++ b/src/config.py @@ -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") diff --git a/src/routers/admin.py b/src/routers/admin.py index 4d91cf4..82863d2 100644 --- a/src/routers/admin.py +++ b/src/routers/admin.py @@ -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, @@ -20,4 +25,53 @@ 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} \ No newline at end of file + 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) \ No newline at end of file diff --git a/src/routers/content.py b/src/routers/content.py index 3637276..033f022 100644 --- a/src/routers/content.py +++ b/src/routers/content.py @@ -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']) diff --git a/src/utils/admin.py b/src/utils/admin.py new file mode 100644 index 0000000..a754bc6 --- /dev/null +++ b/src/utils/admin.py @@ -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)}" + ) \ No newline at end of file diff --git a/src/utils/database.py b/src/utils/database.py index 85d2768..f01cfde 100644 --- a/src/utils/database.py +++ b/src/utils/database.py @@ -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 @@ -33,4 +34,21 @@ except RedisError as re: raise HTTPException(status_code=500, detail="Database connection error") except Exception as e: logger.error(f"Unexpected error while connecting to Redis: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file + 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 \ No newline at end of file