Compare commits

...

10 Commits

Author SHA1 Message Date
dm
b8e7860970 added session manager, but in local dict 2025-09-28 21:30:17 +03:00
dm
10ce121448 update readme 2025-09-10 14:13:03 +03:00
dm
ba48ff5c1c Merge branch 'security' 2025-09-04 11:47:29 +03:00
dm
19b2afcb43 added hash system 2025-09-04 11:42:33 +03:00
dm
1032c08a40 added cache and FileResponse for range requests to seek meia 2025-08-31 19:16:15 +03:00
dm
c256f8834e moved to ftp server 2025-08-31 18:35:37 +03:00
dm
7596ce830d fix compose volume err 2025-08-31 17:09:37 +03:00
dm
45ed8f9117 added ftp support 2025-08-31 16:12:29 +03:00
dm
8c0bff24bd new ideas 2025-08-31 13:59:13 +03:00
dm
90ade2d497 Update 'README.md' 2025-08-29 08:58:54 +00:00
5 changed files with 139 additions and 34 deletions

5
.env
View File

@@ -1,4 +1,7 @@
FILES_DIR="./files"
API_KEY="aboba"
API_KEY_HASH="a6c79a27049109e472b246b5dfbe08aedff1e9e2259597e54032dbad4958d4ad"
FILES_PADDING="5"
DATABASE_NAME="files.db"
FTP_URL="ftp"
FTP_LOGIN="sfs"
FTP_PASSWORD="MySecurePass123!"

View File

@@ -2,26 +2,17 @@
simple file server
### .env example
```
FILES_DIR="./files"
API_KEY="aboba"
FILES_PADDING="5"
DATABASE_NAME="files.db"
```
Working mpv
- [x] get/post files
- [x] security from dublicats
- [x] security, using tokens
- [x] works in docker
- [ ] make it use ftp server
- [ ] make it use db
- [x] make it use ftp server
- [ ] using cookies and temporary tokens
- [ ] deploy on gogs webhooks
- [ ] crypt files
- [ ] multiuser system
- [ ] files ownhership, owner and groups, roles
- [ ] archive
- [ ] share link

View File

@@ -1,21 +1,32 @@
from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Security
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Security, Response, Cookie
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from sqlalchemy import exists
import hashlib
from ftplib import FTP
from io import BytesIO
from . import db
from .session_manager import SessionManager
from dotenv import load_dotenv
import os
import hmac
load_dotenv()
FILES_DIR = os.getenv("FILES_DIR")
API_KEY = os.getenv("API_KEY")
API_KEY_HASH = os.getenv("API_KEY_HASH")
api_key_header = APIKeyHeader(name="X-API-Key")
FTP_URL = os.getenv("FTP_URL")
FTP_LOGIN = os.getenv("FTP_LOGIN")
FTP_PASSWORD = os.getenv("FTP_PASSWORD")
CACHE_DIR = "cache"
def verify_api_key(api_key: str = Security(api_key_header)):
if api_key != API_KEY:
raise HTTPException(status_code=403, detail="Forbidden")
api_key_hashed = hashlib.sha256(api_key.encode()).hexdigest()
if not hmac.compare_digest(api_key_hashed, API_KEY_HASH):
raise HTTPException(status_code=403, detail="Forbidden. (╥﹏╥)")
return api_key
def compute_hash(data: bytes, algorithm="sha256") -> str:
@@ -23,13 +34,40 @@ def compute_hash(data: bytes, algorithm="sha256") -> str:
h.update(data)
return h.hexdigest()
app = FastAPI()
def set_cookie(response: Response, name: str, value: str, max_age: int) -> None:
response.set_cookie(
key=name,
value=value,
httponly=True,
secure=True,
samesite="Strict",
max_age=max_age,
)
TOKEN_TTL = 60*15
session_manager = SessionManager(TOKEN_TTL)
app = FastAPI()
@app.get("/")
async def root():
return {"message": "hiii from sfs"}
@app.post("/login")
def login(response: Response):
user_id = "user123"
token = session_manager.create(user_id)
set_cookie(response, "token", token, TOKEN_TTL)
return {"message": "logged in"}
def get_current_user(token: str = Cookie(None)) -> str:
return session_manager.validate(token)
@app.get("/protected")
def protected_route(user: str = Depends(get_current_user)):
return {"message": f"Hello {user}, you are authenticated!"}
@app.post("/file")
async def save_file(file: UploadFile = File(...), api_key: str = Depends(verify_api_key)):
contents = await file.read()
@@ -42,37 +80,66 @@ async def save_file(file: UploadFile = File(...), api_key: str = Depends(verify_
file_url = db.add_file(file.filename, file.content_type, file.size, hash)
try:
with open(f"{FILES_DIR}/{file_url}", "wb") as f:
f.write(contents)
ftp = FTP(FTP_URL)
ftp.login(FTP_LOGIN, FTP_PASSWORD)
buffer = BytesIO(contents)
try:
ftp.mkd(FILES_DIR)
except:
pass
ftp.storbinary(f"STOR {FILES_DIR}/{file_url}", buffer)
ftp.quit()
cached = os.path.join(CACHE_DIR, file_url)
if os.path.exists(cached):
os.remove(cached)
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}")
async def get_file(filename: str, raw: bool = False, api_key: str = Depends(verify_api_key)):
file_path = os.path.join(FILES_DIR, filename)
local_path = os.path.join(CACHE_DIR, filename)
if not os.path.exists(local_path):
try:
ftp = FTP(FTP_URL)
ftp.login(FTP_LOGIN, FTP_PASSWORD)
with open(local_path, "wb") as f:
ftp.retrbinary(f"RETR {FILES_DIR}/{filename}", f.write)
ftp.quit()
except Exception as e:
raise HTTPException(status_code=404, detail=f"File not found: {e}")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found")
if raw:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
with open(local_path, "r", encoding="utf-8", errors="ignore") as f:
return PlainTextResponse(f.read())
return FileResponse(file_path, filename=filename)
return FileResponse(local_path, filename=filename)
from ftplib import FTP
@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"}
if not db.remove_file(filename):
return {"status": "error", "message": "no file like that"}
try:
ftp = FTP(FTP_URL)
ftp.login(FTP_LOGIN, FTP_PASSWORD)
ftp.delete(f"{FILES_DIR}/{filename}")
ftp.quit()
return {"status": "deleted"}
except Exception as e:
return {"status": "error", "message": f"Could not delete file {filename}: {e}"}
@app.get("/files/")
async def get_list_of_files(api_key: str = Depends(verify_api_key)):
return db.get_all_files()

35
app/session_manager.py Normal file
View File

@@ -0,0 +1,35 @@
from fastapi import HTTPException
from datetime import datetime, timedelta
import secrets
from typing import Dict, Optional
class SessionManager:
def __init__(self, ttl: int):
self.ttl = ttl
self._tokens: Dict[str, Dict[str, datetime]] = {}
def create(self, user_id: str) -> str:
token = secrets.token_urlsafe(32)
self._tokens[token] = {
"user": user_id,
"expires": datetime.utcnow() + timedelta(seconds=self.ttl),
}
return token
def validate(self, token: Optional[str]) -> str:
self.cleanup()
if not token or token not in self._tokens:
raise HTTPException(status_code=401, detail="Not authenticated")
token_data = self._tokens[token]
if token_data["expires"] < datetime.utcnow():
del self._tokens[token]
raise HTTPException(status_code=401, detail="Session expired")
return token_data["user"]
def cleanup(self) -> None:
now = datetime.utcnow()
expired = [t for t, data in self._tokens.items() if data["expires"] < now]
for t in expired:
del self._tokens[t]

View File

@@ -1,13 +1,22 @@
services:
web:
build: .
container_name: sfs
container_name: sfs.api
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
ftp:
image: delfer/alpine-ftp-server
container_name: sfs.ftp
ports: []
environment:
USERS: "sfs|MySecurePass123!|/home/sfs"
ADDRESS: "ftp"
volumes:
- /opt/sfs:/home/sfs
restart: unless-stopped