Spaces:
Running
Running
Soumik Bose commited on
Commit ·
402ef6a
1
Parent(s): fd049b7
clean: remove all space contents
Browse files- .gitignore +0 -1
- .vscode/settings.json +0 -3
- Dockerfile +0 -30
- README.md +0 -9
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/banner.py +0 -20
- app/core/__pycache__/config.cpython-311.pyc +0 -0
- app/core/__pycache__/logger.cpython-311.pyc +0 -0
- app/core/config.py +0 -41
- app/core/constants.py +0 -1
- app/core/exceptions.py +0 -5
- app/core/logger.py +0 -26
- app/db/__pycache__/session.cpython-311.pyc +0 -0
- app/db/session.py +0 -59
- app/main.py +0 -118
- app/middleware/__pycache__/allowed_methods.cpython-311.pyc +0 -0
- app/middleware/__pycache__/auth.cpython-311.pyc +0 -0
- app/middleware/__pycache__/ip_filter.cpython-311.pyc +0 -0
- app/middleware/__pycache__/logging.cpython-311.pyc +0 -0
- app/middleware/__pycache__/rate_limit.cpython-311.pyc +0 -0
- app/middleware/__pycache__/request_id.cpython-311.pyc +0 -0
- app/middleware/__pycache__/request_size.cpython-311.pyc +0 -0
- app/middleware/__pycache__/security_headers.cpython-311.pyc +0 -0
- app/middleware/allowed_methods.py +0 -15
- app/middleware/auth.py +0 -21
- app/middleware/ip_filter.py +0 -23
- app/middleware/logging.py +0 -33
- app/middleware/rate_limit.py +0 -93
- app/middleware/request_id.py +0 -13
- app/middleware/request_size.py +0 -22
- app/middleware/security_headers.py +0 -27
- app/models/__pycache__/schemas.cpython-311.pyc +0 -0
- app/models/schemas.py +0 -32
- app/routers/__pycache__/files.cpython-311.pyc +0 -0
- app/routers/__pycache__/ping.cpython-311.pyc +0 -0
- app/routers/files.py +0 -83
- app/routers/ping.py +0 -22
- app/services/__pycache__/file_service.cpython-311.pyc +0 -0
- app/services/file_service.py +0 -179
- db/file_service.db +0 -0
- requirements.txt +0 -13
- start.sh +0 -9
.gitignore
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
.env
|
|
|
|
|
|
.vscode/settings.json
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"python-envs.defaultEnvManager": "ms-python.python:system"
|
| 3 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 1 |
-
FROM python:3.11-slim
|
| 2 |
-
|
| 3 |
-
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
-
PYTHONUNBUFFERED=1 \
|
| 5 |
-
PIP_NO_CACHE_DIR=1 \
|
| 6 |
-
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 7 |
-
|
| 8 |
-
WORKDIR /app
|
| 9 |
-
|
| 10 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
-
curl \
|
| 12 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
-
|
| 14 |
-
RUN useradd -m -u 1000 user
|
| 15 |
-
|
| 16 |
-
COPY --chown=user:user requirements.txt .
|
| 17 |
-
|
| 18 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
-
|
| 20 |
-
COPY --chown=user:user . .
|
| 21 |
-
|
| 22 |
-
RUN mkdir -p /app/documents/pdfs /app/documents/images /app/files /app/db \
|
| 23 |
-
&& chown -R user:user /app/documents /app/files /app/db \
|
| 24 |
-
&& chmod +x /app/start.sh
|
| 25 |
-
|
| 26 |
-
USER user
|
| 27 |
-
|
| 28 |
-
EXPOSE 7860
|
| 29 |
-
|
| 30 |
-
CMD ["./start.sh"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: reconciliation-file-service-backup
|
| 3 |
-
emoji: 📁
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: pink
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_file: main.py
|
| 8 |
-
pinned: false
|
| 9 |
-
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__pycache__/main.cpython-311.pyc
DELETED
|
Binary file (5.6 kB)
|
|
|
app/banner.py
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
import pyfiglet
|
| 2 |
-
from rich.console import Console
|
| 3 |
-
from rich.rule import Rule
|
| 4 |
-
from rich.text import Text
|
| 5 |
-
|
| 6 |
-
from app.core.config import settings
|
| 7 |
-
|
| 8 |
-
_console = Console()
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def print_banner() -> None:
|
| 12 |
-
|
| 13 |
-
art = pyfiglet.figlet_format("ValidOps", font="slant")
|
| 14 |
-
|
| 15 |
-
_console.print(Text(art, style="bold cyan"))
|
| 16 |
-
_console.print(f" [bold white]{'Service:':<14}[/bold white] [cyan]{settings.APP_NAME}[/cyan]")
|
| 17 |
-
_console.print(f" [bold white]{'Version:':<14}[/bold white] [cyan]v{settings.APP_VERSION}[/cyan]")
|
| 18 |
-
_console.print(f" [bold white]{'Environment:':<14}[/bold white] [cyan]{settings.ENVIRONMENT}[/cyan]")
|
| 19 |
-
_console.print(Rule(style="dim cyan"))
|
| 20 |
-
_console.print()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/__pycache__/config.cpython-311.pyc
DELETED
|
Binary file (1.9 kB)
|
|
|
app/core/__pycache__/logger.cpython-311.pyc
DELETED
|
Binary file (1.43 kB)
|
|
|
app/core/config.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
from typing import Optional
|
| 3 |
-
from pydantic_settings import BaseSettings
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class Settings(BaseSettings):
|
| 7 |
-
APP_BASE_URL: str = "https://validops-east-3-instance-2.hf.space"
|
| 8 |
-
APP_NAME: str = "reconciliation-backup-file-service"
|
| 9 |
-
APP_VERSION: str = "1.0.0"
|
| 10 |
-
ENVIRONMENT: str = "production"
|
| 11 |
-
|
| 12 |
-
BASE_DIR: Path = Path("documents")
|
| 13 |
-
FILES_DIR: Path = Path("/app/files")
|
| 14 |
-
DATABASE_URL: str = "sqlite:////app/db/file_service.db"
|
| 15 |
-
|
| 16 |
-
MAX_FILE_SIZE_MB: int = 50
|
| 17 |
-
|
| 18 |
-
VALID_API_KEYS: set[str] = set()
|
| 19 |
-
|
| 20 |
-
ALLOWED_ORIGINS: list[str] = ["https://validops-east-1-instance-1.hf.space", "https://validops-east-1-instance-2.hf.space", "https://validops-ai.vercel.app"]
|
| 21 |
-
ALLOWED_METHODS: set[str] = {"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
| 22 |
-
|
| 23 |
-
IP_WHITELIST: Optional[set[str]] = None
|
| 24 |
-
IP_BLACKLIST: set[str] = set()
|
| 25 |
-
|
| 26 |
-
REDIS_URL: str
|
| 27 |
-
RATE_LIMIT_PER_MINUTE: int = 100
|
| 28 |
-
RATE_LIMIT_WINDOW_SECONDS: int = 60
|
| 29 |
-
RATE_LIMIT_KEY_PREFIX: str = "rl"
|
| 30 |
-
|
| 31 |
-
MAX_REQUEST_SIZE_MB: int = 50
|
| 32 |
-
TRUSTED_PROXIES: set[str] = {"127.0.0.1"}
|
| 33 |
-
|
| 34 |
-
REQUEST_ID_HEADER: str = "X-Request-ID"
|
| 35 |
-
|
| 36 |
-
class Config:
|
| 37 |
-
env_file = ".env"
|
| 38 |
-
extra = "ignore"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
settings = Settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/constants.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
# No specific constants required for the current implementation.
|
|
|
|
|
|
app/core/exceptions.py
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
from fastapi import HTTPException, status
|
| 2 |
-
|
| 3 |
-
class FileServiceException(HTTPException):
|
| 4 |
-
def __init__(self, status_code: int, detail: str):
|
| 5 |
-
super().__init__(status_code=status_code, detail=detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/logger.py
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import sys
|
| 3 |
-
from functools import lru_cache
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
| 7 |
-
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
@lru_cache(maxsize=None)
|
| 11 |
-
def get_logger(name: str) -> logging.Logger:
|
| 12 |
-
logger = logging.getLogger(name)
|
| 13 |
-
|
| 14 |
-
if logger.handlers:
|
| 15 |
-
return logger
|
| 16 |
-
|
| 17 |
-
logger.setLevel(logging.DEBUG)
|
| 18 |
-
|
| 19 |
-
handler = logging.StreamHandler(sys.stdout)
|
| 20 |
-
handler.setLevel(logging.DEBUG)
|
| 21 |
-
handler.setFormatter(logging.Formatter(fmt=LOG_FORMAT, datefmt=DATE_FORMAT))
|
| 22 |
-
|
| 23 |
-
logger.addHandler(handler)
|
| 24 |
-
logger.propagate = False
|
| 25 |
-
|
| 26 |
-
return logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/db/__pycache__/session.cpython-311.pyc
DELETED
|
Binary file (2.22 kB)
|
|
|
app/db/session.py
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
from datetime import datetime, timezone
|
| 2 |
-
from typing import AsyncGenerator
|
| 3 |
-
|
| 4 |
-
from sqlalchemy import Column, DateTime, Integer, String
|
| 5 |
-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 6 |
-
from sqlalchemy.orm import DeclarativeBase
|
| 7 |
-
|
| 8 |
-
from app.core.config import settings
|
| 9 |
-
|
| 10 |
-
_async_url = settings.DATABASE_URL.replace("sqlite:///", "sqlite+aiosqlite:///")
|
| 11 |
-
|
| 12 |
-
_engine = create_async_engine(
|
| 13 |
-
_async_url,
|
| 14 |
-
connect_args={"check_same_thread": False},
|
| 15 |
-
pool_pre_ping=True,
|
| 16 |
-
echo=False,
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
-
_AsyncSessionLocal = async_sessionmaker(
|
| 20 |
-
bind=_engine,
|
| 21 |
-
class_=AsyncSession,
|
| 22 |
-
expire_on_commit=False,
|
| 23 |
-
autoflush=False,
|
| 24 |
-
autocommit=False,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class _Base(DeclarativeBase):
|
| 29 |
-
pass
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class FileRecord(_Base):
|
| 33 |
-
__tablename__ = "files"
|
| 34 |
-
|
| 35 |
-
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 36 |
-
fileid = Column(String, unique=True, index=True, nullable=False)
|
| 37 |
-
filename = Column(String, nullable=False)
|
| 38 |
-
file_url = Column(String, nullable=False)
|
| 39 |
-
createdat = Column(
|
| 40 |
-
DateTime(timezone=True),
|
| 41 |
-
default=lambda: datetime.now(timezone.utc),
|
| 42 |
-
nullable=False,
|
| 43 |
-
)
|
| 44 |
-
updatedat = Column(
|
| 45 |
-
DateTime(timezone=True),
|
| 46 |
-
default=lambda: datetime.now(timezone.utc),
|
| 47 |
-
onupdate=lambda: datetime.now(timezone.utc),
|
| 48 |
-
nullable=False,
|
| 49 |
-
)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
async def init_db() -> None:
|
| 53 |
-
async with _engine.begin() as conn:
|
| 54 |
-
await conn.run_sync(_Base.metadata.create_all)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 58 |
-
async with _AsyncSessionLocal() as session:
|
| 59 |
-
yield session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
DELETED
|
@@ -1,118 +0,0 @@
|
|
| 1 |
-
import shutil
|
| 2 |
-
from contextlib import asynccontextmanager
|
| 3 |
-
|
| 4 |
-
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
| 5 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
-
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
| 7 |
-
from fastapi.responses import FileResponse, Response
|
| 8 |
-
from sqlalchemy import select
|
| 9 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
-
|
| 11 |
-
from app.core.config import settings
|
| 12 |
-
from app.core.logger import get_logger
|
| 13 |
-
from app.db.session import FileRecord, get_db, init_db
|
| 14 |
-
from app.middleware.allowed_methods import AllowedMethodsMiddleware
|
| 15 |
-
from app.middleware.auth import AuthMiddleware
|
| 16 |
-
from app.middleware.ip_filter import IPFilterMiddleware
|
| 17 |
-
from app.middleware.logging import LoggingMiddleware
|
| 18 |
-
from app.middleware.rate_limit import RateLimitMiddleware
|
| 19 |
-
from app.middleware.request_id import RequestIDMiddleware
|
| 20 |
-
from app.middleware.request_size import RequestSizeLimitMiddleware
|
| 21 |
-
from app.middleware.security_headers import SecurityHeadersMiddleware
|
| 22 |
-
from app.routers import files, ping
|
| 23 |
-
from app.services.file_service import file_service
|
| 24 |
-
|
| 25 |
-
logger = get_logger(__name__)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _get_storage_info() -> dict:
|
| 29 |
-
total, used, free = shutil.disk_usage("/")
|
| 30 |
-
gb = 2**30
|
| 31 |
-
return {
|
| 32 |
-
"total_gb": round(total / gb, 2),
|
| 33 |
-
"used_gb": round(used / gb, 2),
|
| 34 |
-
"free_gb": round(free / gb, 2),
|
| 35 |
-
"percent_used": f"{(used / total) * 100:.2f}%",
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@asynccontextmanager
|
| 40 |
-
async def lifespan(app: FastAPI):
|
| 41 |
-
await init_db()
|
| 42 |
-
storage = _get_storage_info()
|
| 43 |
-
logger.info(
|
| 44 |
-
"Storage: %sGB free of %sGB (%s used)",
|
| 45 |
-
storage["free_gb"],
|
| 46 |
-
storage["total_gb"],
|
| 47 |
-
storage["percent_used"],
|
| 48 |
-
)
|
| 49 |
-
yield
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
app = FastAPI(
|
| 53 |
-
title=settings.APP_NAME,
|
| 54 |
-
version=settings.APP_VERSION,
|
| 55 |
-
lifespan=lifespan,
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
app.add_middleware(RequestIDMiddleware)
|
| 59 |
-
app.add_middleware(LoggingMiddleware)
|
| 60 |
-
app.add_middleware(SecurityHeadersMiddleware)
|
| 61 |
-
app.add_middleware(IPFilterMiddleware)
|
| 62 |
-
app.add_middleware(RateLimitMiddleware)
|
| 63 |
-
app.add_middleware(RequestSizeLimitMiddleware)
|
| 64 |
-
app.add_middleware(AllowedMethodsMiddleware)
|
| 65 |
-
app.add_middleware(AuthMiddleware)
|
| 66 |
-
|
| 67 |
-
app.add_middleware(
|
| 68 |
-
CORSMiddleware,
|
| 69 |
-
allow_origins=settings.ALLOWED_ORIGINS,
|
| 70 |
-
allow_credentials=True,
|
| 71 |
-
allow_methods=list(settings.ALLOWED_METHODS),
|
| 72 |
-
allow_headers=["Authorization", "X-API-Key", "Content-Type", "X-Request-ID"],
|
| 73 |
-
expose_headers=["X-Request-ID", "Content-Length", "Content-Type", "Accept-Ranges"],
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
app.add_middleware(
|
| 77 |
-
TrustedHostMiddleware,
|
| 78 |
-
allowed_hosts=["*"],
|
| 79 |
-
)
|
| 80 |
-
|
| 81 |
-
app.include_router(ping.router, tags=["ping"])
|
| 82 |
-
app.include_router(files.router, prefix="/api/v1/files", tags=["files"])
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
@app.api_route("/v1/getfile", methods=["GET", "HEAD"])
|
| 86 |
-
async def serve_file(
|
| 87 |
-
request: Request,
|
| 88 |
-
fileid: str = Query(..., description="UUID of the file to serve"),
|
| 89 |
-
db: AsyncSession = Depends(get_db),
|
| 90 |
-
):
|
| 91 |
-
result = await db.execute(select(FileRecord).where(FileRecord.fileid == fileid))
|
| 92 |
-
db_record = result.scalar_one_or_none()
|
| 93 |
-
if not db_record:
|
| 94 |
-
logger.warning("File not found in database: %s", fileid)
|
| 95 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 96 |
-
|
| 97 |
-
file_path = file_service.get_file_path(fileid)
|
| 98 |
-
mime_type = file_service.get_mime_type(db_record.filename) or "application/octet-stream"
|
| 99 |
-
file_size = file_path.stat().st_size
|
| 100 |
-
|
| 101 |
-
base_headers = {
|
| 102 |
-
"Content-Disposition": f'inline; filename="{db_record.filename}"',
|
| 103 |
-
"Content-Length": str(file_size),
|
| 104 |
-
"Accept-Ranges": "bytes",
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
if request.method == "HEAD":
|
| 108 |
-
return Response(
|
| 109 |
-
status_code=200,
|
| 110 |
-
headers={**base_headers, "Content-Type": mime_type},
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
-
logger.info("Serving file: %s (%s)", db_record.filename, fileid)
|
| 114 |
-
return FileResponse(
|
| 115 |
-
path=file_path,
|
| 116 |
-
media_type=mime_type,
|
| 117 |
-
headers=base_headers,
|
| 118 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/__pycache__/allowed_methods.cpython-311.pyc
DELETED
|
Binary file (1.41 kB)
|
|
|
app/middleware/__pycache__/auth.cpython-311.pyc
DELETED
|
Binary file (1.88 kB)
|
|
|
app/middleware/__pycache__/ip_filter.cpython-311.pyc
DELETED
|
Binary file (2.06 kB)
|
|
|
app/middleware/__pycache__/logging.cpython-311.pyc
DELETED
|
Binary file (1.92 kB)
|
|
|
app/middleware/__pycache__/rate_limit.cpython-311.pyc
DELETED
|
Binary file (3.47 kB)
|
|
|
app/middleware/__pycache__/request_id.cpython-311.pyc
DELETED
|
Binary file (1.31 kB)
|
|
|
app/middleware/__pycache__/request_size.cpython-311.pyc
DELETED
|
Binary file (1.56 kB)
|
|
|
app/middleware/__pycache__/security_headers.cpython-311.pyc
DELETED
|
Binary file (1.72 kB)
|
|
|
app/middleware/allowed_methods.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
-
from starlette.requests import Request
|
| 3 |
-
from starlette.responses import JSONResponse
|
| 4 |
-
from app.core.config import settings
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class AllowedMethodsMiddleware(BaseHTTPMiddleware):
|
| 8 |
-
async def dispatch(self, request: Request, call_next):
|
| 9 |
-
if request.method not in settings.ALLOWED_METHODS:
|
| 10 |
-
return JSONResponse(
|
| 11 |
-
status_code=405,
|
| 12 |
-
content={"detail": f"Method '{request.method}' is not allowed."},
|
| 13 |
-
headers={"Allow": ", ".join(settings.ALLOWED_METHODS)},
|
| 14 |
-
)
|
| 15 |
-
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/auth.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 1 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
-
from starlette.requests import Request
|
| 3 |
-
from starlette.responses import JSONResponse
|
| 4 |
-
from app.core.config import settings
|
| 5 |
-
from app.core.logger import get_logger
|
| 6 |
-
|
| 7 |
-
logger = get_logger(__name__)
|
| 8 |
-
|
| 9 |
-
class AuthMiddleware(BaseHTTPMiddleware):
|
| 10 |
-
async def dispatch(self, request: Request, call_next):
|
| 11 |
-
if request.method == "POST" and ("/api/v1/files/upload" in request.url.path or "/api/v1/files/bulk-upload" in request.url.path):
|
| 12 |
-
api_key = request.headers.get("X-API-Key")
|
| 13 |
-
if not api_key or api_key not in settings.VALID_API_KEYS:
|
| 14 |
-
logger.warning(f"Unauthorized access attempt to {request.url.path}")
|
| 15 |
-
return JSONResponse(
|
| 16 |
-
status_code=401,
|
| 17 |
-
content={"detail": "Invalid or missing API key."},
|
| 18 |
-
)
|
| 19 |
-
logger.info(f"Authorized access to {request.url.path}")
|
| 20 |
-
|
| 21 |
-
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/ip_filter.py
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
-
from starlette.requests import Request
|
| 3 |
-
from starlette.responses import JSONResponse
|
| 4 |
-
from app.core.config import settings
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class IPFilterMiddleware(BaseHTTPMiddleware):
|
| 8 |
-
async def dispatch(self, request: Request, call_next):
|
| 9 |
-
client_ip = self._resolve_client_ip(request)
|
| 10 |
-
|
| 11 |
-
if settings.IP_BLACKLIST and client_ip in settings.IP_BLACKLIST:
|
| 12 |
-
return JSONResponse(status_code=403, content={"detail": "Access denied."})
|
| 13 |
-
|
| 14 |
-
if settings.IP_WHITELIST and client_ip not in settings.IP_WHITELIST:
|
| 15 |
-
return JSONResponse(status_code=403, content={"detail": "Access denied."})
|
| 16 |
-
|
| 17 |
-
return await call_next(request)
|
| 18 |
-
|
| 19 |
-
def _resolve_client_ip(self, request: Request) -> str:
|
| 20 |
-
forwarded_for = request.headers.get("X-Forwarded-For")
|
| 21 |
-
if forwarded_for:
|
| 22 |
-
return forwarded_for.split(",")[0].strip()
|
| 23 |
-
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/logging.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
import time
|
| 2 |
-
import logging
|
| 3 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 4 |
-
from starlette.requests import Request
|
| 5 |
-
from starlette.responses import Response
|
| 6 |
-
|
| 7 |
-
logger = logging.getLogger("fileserver.access")
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class LoggingMiddleware(BaseHTTPMiddleware):
|
| 11 |
-
async def dispatch(self, request: Request, call_next) -> Response:
|
| 12 |
-
start = time.perf_counter()
|
| 13 |
-
response = await call_next(request)
|
| 14 |
-
duration_ms = (time.perf_counter() - start) * 1000
|
| 15 |
-
|
| 16 |
-
client_ip = getattr(request.state, "client_ip", None) or (
|
| 17 |
-
request.client.host if request.client else "unknown"
|
| 18 |
-
)
|
| 19 |
-
request_id = getattr(request.state, "request_id", "-")
|
| 20 |
-
auth_method = getattr(request.state, "auth_method", "none")
|
| 21 |
-
|
| 22 |
-
logger.info(
|
| 23 |
-
"%s %s %s | ip=%s | req_id=%s | auth=%s | %.2fms",
|
| 24 |
-
request.method,
|
| 25 |
-
request.url.path,
|
| 26 |
-
response.status_code,
|
| 27 |
-
client_ip,
|
| 28 |
-
request_id,
|
| 29 |
-
auth_method,
|
| 30 |
-
duration_ms,
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/rate_limit.py
DELETED
|
@@ -1,93 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 3 |
-
from starlette.requests import Request
|
| 4 |
-
from starlette.responses import JSONResponse
|
| 5 |
-
import redis.asyncio as aioredis
|
| 6 |
-
from app.core.config import settings
|
| 7 |
-
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
-
|
| 10 |
-
SLIDING_WINDOW_LUA = """
|
| 11 |
-
local key = KEYS[1]
|
| 12 |
-
local now = tonumber(ARGV[1])
|
| 13 |
-
local window_ms = tonumber(ARGV[2])
|
| 14 |
-
local limit = tonumber(ARGV[3])
|
| 15 |
-
local cutoff = now - window_ms
|
| 16 |
-
|
| 17 |
-
redis.call("ZREMRANGEBYSCORE", key, "-inf", cutoff)
|
| 18 |
-
|
| 19 |
-
local count = redis.call("ZCARD", key)
|
| 20 |
-
|
| 21 |
-
if count >= limit then
|
| 22 |
-
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
|
| 23 |
-
local retry_at = 0
|
| 24 |
-
if oldest and oldest[2] then
|
| 25 |
-
retry_at = math.ceil((tonumber(oldest[2]) + window_ms - now) / 1000)
|
| 26 |
-
end
|
| 27 |
-
return {0, retry_at}
|
| 28 |
-
end
|
| 29 |
-
|
| 30 |
-
redis.call("ZADD", key, now, now .. "-" .. math.random(1, 1000000))
|
| 31 |
-
redis.call("PEXPIRE", key, window_ms)
|
| 32 |
-
|
| 33 |
-
return {1, 0}
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
EXEMPT_PATHS = {"/ping", "/api/v1/files/get"}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class RateLimitMiddleware(BaseHTTPMiddleware):
|
| 40 |
-
def __init__(self, app):
|
| 41 |
-
super().__init__(app)
|
| 42 |
-
self._limit = settings.RATE_LIMIT_PER_MINUTE
|
| 43 |
-
self._window_ms = settings.RATE_LIMIT_WINDOW_SECONDS * 1000
|
| 44 |
-
self._key_prefix = settings.RATE_LIMIT_KEY_PREFIX
|
| 45 |
-
self._redis: aioredis.Redis = aioredis.from_url(
|
| 46 |
-
settings.REDIS_URL,
|
| 47 |
-
encoding="utf-8",
|
| 48 |
-
decode_responses=True,
|
| 49 |
-
socket_connect_timeout=2,
|
| 50 |
-
socket_timeout=2,
|
| 51 |
-
)
|
| 52 |
-
self._script = self._redis.register_script(SLIDING_WINDOW_LUA)
|
| 53 |
-
|
| 54 |
-
async def dispatch(self, request: Request, call_next):
|
| 55 |
-
if request.url.path in EXEMPT_PATHS:
|
| 56 |
-
return await call_next(request)
|
| 57 |
-
|
| 58 |
-
client_ip = (
|
| 59 |
-
getattr(request.state, "client_ip", None)
|
| 60 |
-
or request.headers.get("x-real-ip")
|
| 61 |
-
or (request.headers.get("x-forwarded-for", "").split(",")[0].strip() or None)
|
| 62 |
-
or (request.client.host if request.client else "unknown")
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
allowed, retry_after = await self._check(client_ip)
|
| 66 |
-
|
| 67 |
-
if not allowed:
|
| 68 |
-
return JSONResponse(
|
| 69 |
-
status_code=429,
|
| 70 |
-
content={"detail": "Too many requests. Please slow down."},
|
| 71 |
-
headers={"Retry-After": str(max(1, retry_after))},
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
return await call_next(request)
|
| 75 |
-
|
| 76 |
-
async def _check(self, client_ip: str) -> tuple[bool, int]:
|
| 77 |
-
import time
|
| 78 |
-
|
| 79 |
-
key = f"{self._key_prefix}:{client_ip}"
|
| 80 |
-
now_ms = int(time.time() * 1000)
|
| 81 |
-
|
| 82 |
-
try:
|
| 83 |
-
result = await self._script(
|
| 84 |
-
keys=[key],
|
| 85 |
-
args=[now_ms, self._window_ms, self._limit],
|
| 86 |
-
)
|
| 87 |
-
return bool(result[0]), int(result[1])
|
| 88 |
-
except Exception as exc:
|
| 89 |
-
logger.error("Rate limiter Redis error — failing open: %s", exc)
|
| 90 |
-
return True, 0
|
| 91 |
-
|
| 92 |
-
async def close(self):
|
| 93 |
-
await self._redis.aclose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/request_id.py
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
import uuid
|
| 2 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 3 |
-
from starlette.requests import Request
|
| 4 |
-
from starlette.responses import Response
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class RequestIDMiddleware(BaseHTTPMiddleware):
|
| 8 |
-
async def dispatch(self, request: Request, call_next) -> Response:
|
| 9 |
-
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
| 10 |
-
request.state.request_id = request_id
|
| 11 |
-
response = await call_next(request)
|
| 12 |
-
response.headers["X-Request-ID"] = request_id
|
| 13 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/request_size.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
-
from starlette.requests import Request
|
| 3 |
-
from starlette.responses import JSONResponse
|
| 4 |
-
from app.core.config import settings
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
| 8 |
-
async def dispatch(self, request: Request, call_next):
|
| 9 |
-
content_length = request.headers.get("Content-Length")
|
| 10 |
-
|
| 11 |
-
if content_length:
|
| 12 |
-
size_bytes = int(content_length)
|
| 13 |
-
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
|
| 14 |
-
if size_bytes > max_bytes:
|
| 15 |
-
return JSONResponse(
|
| 16 |
-
status_code=413,
|
| 17 |
-
content={
|
| 18 |
-
"detail": f"Request body exceeds maximum allowed size of {settings.MAX_FILE_SIZE_MB}MB."
|
| 19 |
-
},
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/middleware/security_headers.py
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 2 |
-
from starlette.requests import Request
|
| 3 |
-
from starlette.responses import Response
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 7 |
-
async def dispatch(self, request: Request, call_next) -> Response:
|
| 8 |
-
response = await call_next(request)
|
| 9 |
-
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
|
| 10 |
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 11 |
-
# response.headers["X-Frame-Options"] = "DENY"
|
| 12 |
-
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 13 |
-
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 14 |
-
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
| 15 |
-
# Relax CSP for file rendering (e.g., PDFs and images)
|
| 16 |
-
response.headers["Content-Security-Policy"] = (
|
| 17 |
-
"default-src 'self'; "
|
| 18 |
-
"script-src 'self'; "
|
| 19 |
-
"style-src 'self'; "
|
| 20 |
-
"img-src 'self' data: *; "
|
| 21 |
-
"object-src 'self' *;"
|
| 22 |
-
)
|
| 23 |
-
# For file serving, we might want to allow caching, but for now we just ensure it doesn't force download
|
| 24 |
-
if not request.url.path.startswith("/v1/getfile"):
|
| 25 |
-
response.headers["Cache-Control"] = "no-store"
|
| 26 |
-
response.headers["Pragma"] = "no-cache"
|
| 27 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models/__pycache__/schemas.cpython-311.pyc
DELETED
|
Binary file (1.98 kB)
|
|
|
app/models/schemas.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
from datetime import datetime
|
| 2 |
-
from typing import List, Optional
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class FileInfo(BaseModel):
|
| 7 |
-
id: int
|
| 8 |
-
fileid: str
|
| 9 |
-
filename: str
|
| 10 |
-
file_url: str
|
| 11 |
-
createdat: datetime
|
| 12 |
-
updatedat: datetime
|
| 13 |
-
|
| 14 |
-
class Config:
|
| 15 |
-
from_attributes = True
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class UploadResponse(BaseModel):
|
| 19 |
-
success: bool
|
| 20 |
-
data: Optional[FileInfo] = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class FileListResponse(BaseModel):
|
| 24 |
-
success: bool
|
| 25 |
-
total: int
|
| 26 |
-
items: List[FileInfo]
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
class DeleteResponse(BaseModel):
|
| 30 |
-
success: bool
|
| 31 |
-
message: str
|
| 32 |
-
fileid: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/routers/__pycache__/files.cpython-311.pyc
DELETED
|
Binary file (3.73 kB)
|
|
|
app/routers/__pycache__/ping.cpython-311.pyc
DELETED
|
Binary file (1.14 kB)
|
|
|
app/routers/files.py
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 1 |
-
from typing import List
|
| 2 |
-
|
| 3 |
-
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
| 4 |
-
from fastapi.responses import FileResponse, Response
|
| 5 |
-
from sqlalchemy import select
|
| 6 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
-
|
| 8 |
-
from app.db.session import FileRecord, get_db
|
| 9 |
-
from app.models.schemas import DeleteResponse, FileInfo, FileListResponse, UploadResponse
|
| 10 |
-
from app.services.file_service import file_service
|
| 11 |
-
|
| 12 |
-
router = APIRouter()
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
@router.post("/upload", response_model=UploadResponse, status_code=201)
|
| 16 |
-
async def upload_file(
|
| 17 |
-
file: UploadFile = File(...),
|
| 18 |
-
db: AsyncSession = Depends(get_db),
|
| 19 |
-
) -> UploadResponse:
|
| 20 |
-
return await file_service.upload_file(db, file)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
@router.post("/bulk-upload", response_model=List[UploadResponse], status_code=201)
|
| 24 |
-
async def bulk_upload_files(
|
| 25 |
-
files: List[UploadFile] = File(...),
|
| 26 |
-
db: AsyncSession = Depends(get_db),
|
| 27 |
-
) -> List[UploadResponse]:
|
| 28 |
-
return await file_service.bulk_upload_files(db, files)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
@router.get("/all", response_model=FileListResponse)
|
| 32 |
-
async def list_files(db: AsyncSession = Depends(get_db)) -> FileListResponse:
|
| 33 |
-
return await file_service.list_files(db)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
@router.get("/get", response_model=FileInfo)
|
| 37 |
-
async def get_file_info(
|
| 38 |
-
fileid: str = Query(..., description="UUID of the uploaded file"),
|
| 39 |
-
db: AsyncSession = Depends(get_db),
|
| 40 |
-
) -> FileInfo:
|
| 41 |
-
return await file_service.get_file_info(db, fileid)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@router.delete("/", response_model=DeleteResponse)
|
| 45 |
-
async def delete_file(
|
| 46 |
-
fileid: str = Query(..., description="UUID of the file to delete"),
|
| 47 |
-
db: AsyncSession = Depends(get_db),
|
| 48 |
-
) -> DeleteResponse:
|
| 49 |
-
return await file_service.delete_file(db, fileid)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
@router.api_route("/getfile", methods=["GET", "HEAD"])
|
| 53 |
-
async def serve_file(
|
| 54 |
-
request: Request,
|
| 55 |
-
fileid: str = Query(..., description="UUID of the file to serve"),
|
| 56 |
-
db: AsyncSession = Depends(get_db),
|
| 57 |
-
):
|
| 58 |
-
result = await db.execute(select(FileRecord).where(FileRecord.fileid == fileid))
|
| 59 |
-
db_record = result.scalar_one_or_none()
|
| 60 |
-
if not db_record:
|
| 61 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 62 |
-
|
| 63 |
-
file_path = file_service.get_file_path(fileid)
|
| 64 |
-
mime_type = file_service.get_mime_type(db_record.filename) or "application/octet-stream"
|
| 65 |
-
file_size = file_path.stat().st_size
|
| 66 |
-
|
| 67 |
-
base_headers = {
|
| 68 |
-
"Content-Disposition": f'inline; filename="{db_record.filename}"',
|
| 69 |
-
"Content-Length": str(file_size),
|
| 70 |
-
"Accept-Ranges": "bytes",
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
if request.method == "HEAD":
|
| 74 |
-
return Response(
|
| 75 |
-
status_code=200,
|
| 76 |
-
headers={**base_headers, "Content-Type": mime_type},
|
| 77 |
-
)
|
| 78 |
-
|
| 79 |
-
return FileResponse(
|
| 80 |
-
path=file_path,
|
| 81 |
-
media_type=mime_type,
|
| 82 |
-
headers=base_headers,
|
| 83 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/routers/ping.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
from fastapi.responses import JSONResponse
|
| 3 |
-
from app.core.config import settings
|
| 4 |
-
|
| 5 |
-
router = APIRouter()
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
@router.get("/", tags=["root"])
|
| 9 |
-
async def root():
|
| 10 |
-
return JSONResponse(
|
| 11 |
-
content={
|
| 12 |
-
"service": settings.APP_NAME,
|
| 13 |
-
"version": settings.APP_VERSION,
|
| 14 |
-
"status": "online",
|
| 15 |
-
"docs": "/docs"
|
| 16 |
-
}
|
| 17 |
-
)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
@router.get("/ping", tags=["ping"])
|
| 21 |
-
async def ping():
|
| 22 |
-
return {"message": f"{settings.APP_NAME} is running..."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/services/__pycache__/file_service.cpython-311.pyc
DELETED
|
Binary file (8.32 kB)
|
|
|
app/services/file_service.py
DELETED
|
@@ -1,179 +0,0 @@
|
|
| 1 |
-
import uuid
|
| 2 |
-
import mimetypes
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
import aiofiles
|
| 6 |
-
import aiofiles.os
|
| 7 |
-
from fastapi import HTTPException, UploadFile
|
| 8 |
-
from sqlalchemy import select
|
| 9 |
-
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
-
|
| 11 |
-
from app.core.config import settings
|
| 12 |
-
from app.core.logger import get_logger
|
| 13 |
-
from app.db.session import FileRecord
|
| 14 |
-
from app.models.schemas import DeleteResponse, FileInfo, FileListResponse, UploadResponse
|
| 15 |
-
|
| 16 |
-
logger = get_logger(__name__)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class FileService:
|
| 20 |
-
RENDERABLE_MIME_TYPES: dict[str, str] = {
|
| 21 |
-
".png": "image/png",
|
| 22 |
-
".jpg": "image/jpeg",
|
| 23 |
-
".jpeg": "image/jpeg",
|
| 24 |
-
".gif": "image/gif",
|
| 25 |
-
".webp": "image/webp",
|
| 26 |
-
".svg": "image/svg+xml",
|
| 27 |
-
".apng": "image/apng",
|
| 28 |
-
".avif": "image/avif",
|
| 29 |
-
".bmp": "image/bmp",
|
| 30 |
-
".ico": "image/vnd.microsoft.icon",
|
| 31 |
-
".tif": "image/tiff",
|
| 32 |
-
".tiff": "image/tiff",
|
| 33 |
-
".aac": "audio/aac",
|
| 34 |
-
".mp3": "audio/mpeg",
|
| 35 |
-
".oga": "audio/ogg",
|
| 36 |
-
".ogg": "audio/ogg",
|
| 37 |
-
".wav": "audio/wav",
|
| 38 |
-
".weba": "audio/webm",
|
| 39 |
-
".midi": "audio/midi",
|
| 40 |
-
".mid": "audio/midi",
|
| 41 |
-
".mp4": "video/mp4",
|
| 42 |
-
".mpeg": "video/mpeg",
|
| 43 |
-
".ogv": "video/ogg",
|
| 44 |
-
".webm": "video/webm",
|
| 45 |
-
".ts": "video/mp2t",
|
| 46 |
-
".pdf": "application/pdf",
|
| 47 |
-
".txt": "text/plain",
|
| 48 |
-
".html": "text/html",
|
| 49 |
-
".htm": "text/html",
|
| 50 |
-
".css": "text/css",
|
| 51 |
-
".js": "text/javascript",
|
| 52 |
-
".json": "application/json",
|
| 53 |
-
".jsonld": "application/ld+json",
|
| 54 |
-
".md": "text/markdown",
|
| 55 |
-
".xml": "application/xml",
|
| 56 |
-
".xhtml": "application/xhtml+xml",
|
| 57 |
-
".csv": "text/csv",
|
| 58 |
-
".ics": "text/calendar",
|
| 59 |
-
".otf": "font/otf",
|
| 60 |
-
".ttf": "font/ttf",
|
| 61 |
-
".woff": "font/woff",
|
| 62 |
-
".woff2": "font/woff2",
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
def __init__(self) -> None:
|
| 66 |
-
self.files_dir = Path(settings.FILES_DIR)
|
| 67 |
-
self.files_dir.mkdir(parents=True, exist_ok=True)
|
| 68 |
-
|
| 69 |
-
def _generate_file_id(self) -> str:
|
| 70 |
-
return f"temp-{uuid.uuid4()}"
|
| 71 |
-
|
| 72 |
-
def _get_file_url(self, fileid: str) -> str:
|
| 73 |
-
return f"{settings.APP_BASE_URL}/v1/getfile?fileid={fileid}"
|
| 74 |
-
|
| 75 |
-
async def upload_file(self, db: AsyncSession, file: UploadFile) -> UploadResponse:
|
| 76 |
-
fileid = self._generate_file_id()
|
| 77 |
-
filename = file.filename
|
| 78 |
-
file_path = self.files_dir / fileid
|
| 79 |
-
|
| 80 |
-
logger.info("Starting file upload: %s", filename)
|
| 81 |
-
|
| 82 |
-
try:
|
| 83 |
-
async with aiofiles.open(file_path, "wb") as buffer:
|
| 84 |
-
while chunk := await file.read(1024 * 1024):
|
| 85 |
-
await buffer.write(chunk)
|
| 86 |
-
|
| 87 |
-
logger.info("File saved to disk: %s", file_path)
|
| 88 |
-
|
| 89 |
-
file_url = self._get_file_url(fileid)
|
| 90 |
-
db_record = FileRecord(fileid=fileid, filename=filename, file_url=file_url)
|
| 91 |
-
db.add(db_record)
|
| 92 |
-
await db.commit()
|
| 93 |
-
await db.refresh(db_record)
|
| 94 |
-
|
| 95 |
-
logger.info("File record saved to database: %s", fileid)
|
| 96 |
-
|
| 97 |
-
return UploadResponse(success=True, data=FileInfo.from_orm(db_record))
|
| 98 |
-
|
| 99 |
-
except Exception as exc:
|
| 100 |
-
logger.error("Error during file upload: %s", str(exc))
|
| 101 |
-
if await aiofiles.os.path.exists(file_path):
|
| 102 |
-
await aiofiles.os.remove(file_path)
|
| 103 |
-
await db.rollback()
|
| 104 |
-
raise HTTPException(status_code=500, detail="Internal server error during file upload")
|
| 105 |
-
|
| 106 |
-
async def bulk_upload_files(self, db: AsyncSession, files: list[UploadFile]) -> list[UploadResponse]:
|
| 107 |
-
responses: list[UploadResponse] = []
|
| 108 |
-
for file in files:
|
| 109 |
-
try:
|
| 110 |
-
response = await self.upload_file(db, file)
|
| 111 |
-
responses.append(response)
|
| 112 |
-
except HTTPException as exc:
|
| 113 |
-
logger.error("Failed to upload file %s: %s", file.filename, exc.detail)
|
| 114 |
-
responses.append(UploadResponse(success=False, data=None))
|
| 115 |
-
except Exception as exc:
|
| 116 |
-
logger.error("Unexpected error during bulk upload for file %s: %s", file.filename, str(exc))
|
| 117 |
-
responses.append(UploadResponse(success=False, data=None))
|
| 118 |
-
return responses
|
| 119 |
-
|
| 120 |
-
async def get_file_info(self, db: AsyncSession, fileid: str) -> FileInfo:
|
| 121 |
-
logger.info("Fetching file info: %s", fileid)
|
| 122 |
-
result = await db.execute(select(FileRecord).where(FileRecord.fileid == fileid))
|
| 123 |
-
db_record = result.scalar_one_or_none()
|
| 124 |
-
if not db_record:
|
| 125 |
-
logger.warning("File not found in database: %s", fileid)
|
| 126 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 127 |
-
return FileInfo.from_orm(db_record)
|
| 128 |
-
|
| 129 |
-
async def list_files(self, db: AsyncSession) -> FileListResponse:
|
| 130 |
-
logger.info("Listing all files")
|
| 131 |
-
result = await db.execute(select(FileRecord))
|
| 132 |
-
records = result.scalars().all()
|
| 133 |
-
items = [FileInfo.from_orm(r) for r in records]
|
| 134 |
-
return FileListResponse(success=True, total=len(items), items=items)
|
| 135 |
-
|
| 136 |
-
async def delete_file(self, db: AsyncSession, fileid: str) -> DeleteResponse:
|
| 137 |
-
logger.info("Starting file deletion: %s", fileid)
|
| 138 |
-
result = await db.execute(select(FileRecord).where(FileRecord.fileid == fileid))
|
| 139 |
-
db_record = result.scalar_one_or_none()
|
| 140 |
-
if not db_record:
|
| 141 |
-
logger.warning("File not found for deletion: %s", fileid)
|
| 142 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 143 |
-
|
| 144 |
-
file_path = self.files_dir / fileid
|
| 145 |
-
try:
|
| 146 |
-
if await aiofiles.os.path.exists(file_path):
|
| 147 |
-
await aiofiles.os.remove(file_path)
|
| 148 |
-
logger.info("File removed from disk: %s", file_path)
|
| 149 |
-
|
| 150 |
-
await db.delete(db_record)
|
| 151 |
-
await db.commit()
|
| 152 |
-
logger.info("File record removed from database: %s", fileid)
|
| 153 |
-
|
| 154 |
-
return DeleteResponse(success=True, message="File deleted successfully", fileid=fileid)
|
| 155 |
-
|
| 156 |
-
except Exception as exc:
|
| 157 |
-
logger.error("Error during file deletion: %s", str(exc))
|
| 158 |
-
await db.rollback()
|
| 159 |
-
raise HTTPException(status_code=500, detail="Internal server error during file deletion")
|
| 160 |
-
|
| 161 |
-
def get_file_path(self, fileid: str) -> Path:
|
| 162 |
-
file_path = self.files_dir / fileid
|
| 163 |
-
if not file_path.exists():
|
| 164 |
-
logger.warning("File path does not exist: %s", file_path)
|
| 165 |
-
raise HTTPException(status_code=404, detail="File not found on disk")
|
| 166 |
-
return file_path
|
| 167 |
-
|
| 168 |
-
def get_mime_type(self, filename: str) -> str:
|
| 169 |
-
extension = Path(filename).suffix.lower()
|
| 170 |
-
if extension in self.RENDERABLE_MIME_TYPES:
|
| 171 |
-
return self.RENDERABLE_MIME_TYPES[extension]
|
| 172 |
-
mime_type, _ = mimetypes.guess_type(filename)
|
| 173 |
-
if mime_type:
|
| 174 |
-
return mime_type
|
| 175 |
-
return "application/octet-stream"
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
file_service = FileService()
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
db/file_service.db
DELETED
|
Binary file (16.4 kB)
|
|
|
requirements.txt
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
fastapi==0.115.0
|
| 2 |
-
uvicorn[standard]==0.30.6
|
| 3 |
-
python-multipart==0.0.9
|
| 4 |
-
pydantic-settings==2.5.2
|
| 5 |
-
aiofiles==24.1.0
|
| 6 |
-
pyJWT==2.10.1
|
| 7 |
-
sqlalchemy==2.0.25
|
| 8 |
-
aiosqlite==0.20.0
|
| 9 |
-
python-dotenv==1.0.0
|
| 10 |
-
httpx>=0.27.0
|
| 11 |
-
redis[hiredis]==6.2.0
|
| 12 |
-
pyfiglet==1.0.2
|
| 13 |
-
rich==14.1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
start.sh
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env bash
|
| 2 |
-
set -e
|
| 3 |
-
|
| 4 |
-
python - <<EOF
|
| 5 |
-
from app.banner import print_banner
|
| 6 |
-
print_banner()
|
| 7 |
-
EOF
|
| 8 |
-
|
| 9 |
-
exec uvicorn app.main:app --host 0.0.0.0 --port 7860 --workers 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|