Merge branch 'master' of gogs:dm/SFS

This commit is contained in:
dm
2025-08-27 09:39:54 +03:00
7 changed files with 130 additions and 14 deletions

1
.gitignore vendored
View File

@@ -59,3 +59,4 @@ docs/_build/
target/
*.db
.env

23
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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}")
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"}

View File

@@ -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}")

13
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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",

24
uv.lock generated
View File

@@ -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" },