diff --git a/.gitignore b/.gitignore index 13fcc76..a61f37c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ docs/_build/ target/ *.db +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23f952c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ + && ln -s /root/.local/bin/uv /usr/local/bin/uv + +ENV XDG_CACHE_HOME=/root/.cache \ + UV_CACHE_DIR=/root/.cache/uv \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY . . + +EXPOSE 8000 +CMD ["uv", "run", "uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/api.py b/app/api.py index 5f983fe..3efb1a1 100644 --- a/app/api.py +++ b/app/api.py @@ -1,10 +1,23 @@ -from fastapi import FastAPI, File, UploadFile, HTTPException +from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Security from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.security import APIKeyHeader from sqlalchemy import exists import hashlib from . import db +from dotenv import load_dotenv import os +load_dotenv() + +FILES_DIR = os.getenv("FILES_DIR") +API_KEY = os.getenv("API_KEY") +api_key_header = APIKeyHeader(name="X-API-Key") + +def verify_api_key(api_key: str = Security(api_key_header)): + if api_key != API_KEY: + raise HTTPException(status_code=403, detail="Forbidden") + return api_key + def compute_hash(data: bytes, algorithm="sha256") -> str: h = hashlib.new(algorithm) h.update(data) @@ -12,14 +25,13 @@ def compute_hash(data: bytes, algorithm="sha256") -> str: app = FastAPI() -FILES_DIR = "./files" @app.get("/") -def root(): +async def root(): return {"message": "hiii from sfs"} @app.post("/file") -async def save_file(file: UploadFile = File(...)): +async def save_file(file: UploadFile = File(...), api_key: str = Depends(verify_api_key)): contents = await file.read() hash = compute_hash(contents) @@ -29,15 +41,18 @@ async def save_file(file: UploadFile = File(...)): if not existed_url: file_url = db.add_file(file.filename, file.content_type, file.size, hash) - print(f"{FILES_DIR}/{file_url}") - with open(f"{FILES_DIR}/{file_url}", "wb") as f: - f.write(contents) - return {"status": "saved", "filename": file_url} + try: + with open(f"{FILES_DIR}/{file_url}", "wb") as f: + f.write(contents) + return {"status": "saved", "filename": file_url} + except Exception as e: + db.remove_file(file_url) + return {"status": "error", "message": f"Could not save file {file_url}: {e}"} else: return {"status": "file_exists", "filename": existed_url} @app.get("/file/{filename}") -def get_file(filename: str, raw: bool = False): +async def get_file(filename: str, raw: bool = False, api_key: str = Depends(verify_api_key)): file_path = os.path.join(FILES_DIR, filename) if not os.path.exists(file_path): @@ -48,8 +63,18 @@ def get_file(filename: str, raw: bool = False): return FileResponse(file_path, filename=filename) + +@app.delete("/file/{filename}") +async def delete_file(filename: str, api_key: str = Depends(verify_api_key)): + if db.remove_file(filename): + file_path = f"{FILES_DIR}/{filename}" + if os.path.exists(file_path): + os.remove(file_path) + return {"status": "deleted"} + return {"status": "error", "message": "no file like that"} + @app.get("/files/") -def get_list_of_files(): +async def get_list_of_files(api_key: str = Depends(verify_api_key)): files = db.get_all_files() return [ { @@ -63,5 +88,5 @@ def get_list_of_files(): ] @app.get("/healthchecker") -def healthchecker(): +async def healthchecker(api_key: str = Depends(verify_api_key)): return {"message": "Howdy :3"} diff --git a/app/db.py b/app/db.py index bb66408..1495907 100644 --- a/app/db.py +++ b/app/db.py @@ -4,9 +4,13 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy import select -PADDING = 5 +from dotenv import load_dotenv +import os +load_dotenv() +PADDING = int(os.getenv("FILES_PADDING")) +DATABASE_NAME = os.getenv("DATABASE_NAME") -engine = create_engine("sqlite:///example.db") +engine = create_engine(f"sqlite:///{DATABASE_NAME}.db") session = Session(bind=engine) class Base(DeclarativeBase): pass @@ -38,6 +42,22 @@ def to_base36(n: int, width: int) -> str: result.append(chars[rem]) return "".join(reversed(result)).rjust(width, "0") + +def from_base36(s: str) -> int: + chars = "0123456789abcdefghijklmnopqrstuvwxyz" + char_to_val = {c: i for i, c in enumerate(chars)} + + s = s.lower().lstrip("0") + if not s: + return 0 + + n = 0 + for ch in s: + if ch not in char_to_val: + raise ValueError(f"Invalid base36 character: {ch}") + n = n * 36 + char_to_val[ch] + return n + Base.metadata.create_all(bind=engine) def get_all_files(): @@ -83,6 +103,17 @@ def add_file(filename: str, content_type, size: int, hash): return url + +def remove_file(file_url: str): + with Session(autoflush=False, bind=engine) as db: + file_id = from_base36(file_url.rsplit(".")[0]) + file = db.get(File, file_id) + if not file: + return None + db.delete(file) + db.commit() + return True + if __name__ == "__main__": for i in get_all_files(): print(f"{i.id} {i.name}.{i.extension} ({i.hash}) {i.content_type} {i.size}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0a8b08b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + web: + build: . + container_name: sfs + command: uv run uvicorn app.api:app --host 0.0.0.0 --port 8000 + ports: + - "8000:8000" + volumes: + - .:/app + - ./files:/app/files + env_file: + - .env + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 541b9d7..44c7bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "dotenv>=0.9.9", "fastapi>=0.116.1", "python-multipart>=0.0.20", "sqlalchemy>=2.0.43", diff --git a/uv.lock b/uv.lock index fb9fe12..46a5997 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -46,6 +46,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + [[package]] name = "fastapi" version = "0.116.1" @@ -168,6 +179,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + [[package]] name = "python-multipart" version = "0.0.20" @@ -182,6 +202,7 @@ name = "sfs" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "dotenv" }, { name = "fastapi" }, { name = "python-multipart" }, { name = "sqlalchemy" }, @@ -190,6 +211,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.116.1" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sqlalchemy", specifier = ">=2.0.43" },