Compare commits
10 Commits
1295a8fdc1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8e7860970 | ||
|
|
10ce121448 | ||
|
|
ba48ff5c1c | ||
|
|
19b2afcb43 | ||
|
|
1032c08a40 | ||
|
|
c256f8834e | ||
|
|
7596ce830d | ||
|
|
45ed8f9117 | ||
|
|
8c0bff24bd | ||
| 90ade2d497 |
5
.env
5
.env
@@ -1,4 +1,7 @@
|
|||||||
FILES_DIR="./files"
|
FILES_DIR="./files"
|
||||||
API_KEY="aboba"
|
API_KEY_HASH="a6c79a27049109e472b246b5dfbe08aedff1e9e2259597e54032dbad4958d4ad"
|
||||||
FILES_PADDING="5"
|
FILES_PADDING="5"
|
||||||
DATABASE_NAME="files.db"
|
DATABASE_NAME="files.db"
|
||||||
|
FTP_URL="ftp"
|
||||||
|
FTP_LOGIN="sfs"
|
||||||
|
FTP_PASSWORD="MySecurePass123!"
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -2,26 +2,17 @@
|
|||||||
|
|
||||||
simple file server
|
simple file server
|
||||||
|
|
||||||
### .env example
|
|
||||||
```
|
|
||||||
FILES_DIR="./files"
|
|
||||||
API_KEY="aboba"
|
|
||||||
FILES_PADDING="5"
|
|
||||||
DATABASE_NAME="files.db"
|
|
||||||
```
|
|
||||||
|
|
||||||
Working mpv
|
Working mpv
|
||||||
|
|
||||||
- [x] get/post files
|
- [x] get/post files
|
||||||
- [x] security from dublicats
|
- [x] security from dublicats
|
||||||
- [x] security, using tokens
|
- [x] security, using tokens
|
||||||
- [x] works in docker
|
- [x] works in docker
|
||||||
|
- [x] make it use ftp server
|
||||||
|
- [ ] using cookies and temporary tokens
|
||||||
- [ ] make it use ftp server
|
- [ ] deploy on gogs webhooks
|
||||||
- [ ] make it use db
|
- [ ] crypt files
|
||||||
- [ ] multiuser system
|
- [ ] multiuser system
|
||||||
- [ ] files ownhership, owner and groups, roles
|
- [ ] files ownhership, owner and groups, roles
|
||||||
- [ ] archive
|
- [ ] archive
|
||||||
- [ ] share link
|
- [ ] share link
|
||||||
|
|
||||||
|
|||||||
103
app/api.py
103
app/api.py
@@ -1,21 +1,32 @@
|
|||||||
from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Security
|
from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Security, Response, Cookie
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
from sqlalchemy import exists
|
from sqlalchemy import exists
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from ftplib import FTP
|
||||||
|
from io import BytesIO
|
||||||
from . import db
|
from . import db
|
||||||
|
from .session_manager import SessionManager
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import os
|
import os
|
||||||
|
import hmac
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
FILES_DIR = os.getenv("FILES_DIR")
|
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")
|
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)):
|
def verify_api_key(api_key: str = Security(api_key_header)):
|
||||||
if api_key != API_KEY:
|
api_key_hashed = hashlib.sha256(api_key.encode()).hexdigest()
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
if not hmac.compare_digest(api_key_hashed, API_KEY_HASH):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden. (╥﹏╥)")
|
||||||
|
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
def compute_hash(data: bytes, algorithm="sha256") -> str:
|
def compute_hash(data: bytes, algorithm="sha256") -> str:
|
||||||
@@ -23,13 +34,40 @@ def compute_hash(data: bytes, algorithm="sha256") -> str:
|
|||||||
h.update(data)
|
h.update(data)
|
||||||
return h.hexdigest()
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "hiii from sfs"}
|
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")
|
@app.post("/file")
|
||||||
async def save_file(file: UploadFile = File(...), api_key: str = Depends(verify_api_key)):
|
async def save_file(file: UploadFile = File(...), api_key: str = Depends(verify_api_key)):
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
@@ -42,36 +80,65 @@ 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)
|
file_url = db.add_file(file.filename, file.content_type, file.size, hash)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(f"{FILES_DIR}/{file_url}", "wb") as f:
|
ftp = FTP(FTP_URL)
|
||||||
f.write(contents)
|
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}
|
return {"status": "saved", "filename": file_url}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.remove_file(file_url)
|
db.remove_file(file_url)
|
||||||
|
|
||||||
return {"status": "error", "message": f"Could not save file {file_url}: {e}"}
|
return {"status": "error", "message": f"Could not save file {file_url}: {e}"}
|
||||||
else:
|
else:
|
||||||
return {"status": "file_exists", "filename": existed_url}
|
return {"status": "file_exists", "filename": existed_url}
|
||||||
|
|
||||||
@app.get("/file/{filename}")
|
@app.get("/file/{filename}")
|
||||||
async def get_file(filename: str, raw: bool = False, api_key: str = Depends(verify_api_key)):
|
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:
|
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 PlainTextResponse(f.read())
|
||||||
|
|
||||||
return FileResponse(file_path, filename=filename)
|
return FileResponse(local_path, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
from ftplib import FTP
|
||||||
|
|
||||||
@app.delete("/file/{filename}")
|
@app.delete("/file/{filename}")
|
||||||
async def delete_file(filename: str, api_key: str = Depends(verify_api_key)):
|
async def delete_file(filename: str, api_key: str = Depends(verify_api_key)):
|
||||||
if db.remove_file(filename):
|
if not db.remove_file(filename):
|
||||||
file_path = f"{FILES_DIR}/{filename}"
|
return {"status": "error", "message": "no file like that"}
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
try:
|
||||||
|
ftp = FTP(FTP_URL)
|
||||||
|
ftp.login(FTP_LOGIN, FTP_PASSWORD)
|
||||||
|
ftp.delete(f"{FILES_DIR}/{filename}")
|
||||||
|
ftp.quit()
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
return {"status": "error", "message": "no file like that"}
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": f"Could not delete file {filename}: {e}"}
|
||||||
|
|
||||||
@app.get("/files/")
|
@app.get("/files/")
|
||||||
async def get_list_of_files(api_key: str = Depends(verify_api_key)):
|
async def get_list_of_files(api_key: str = Depends(verify_api_key)):
|
||||||
|
|||||||
35
app/session_manager.py
Normal file
35
app/session_manager.py
Normal 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]
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: sfs
|
container_name: sfs.api
|
||||||
command: uv run uvicorn app.api:app --host 0.0.0.0 --port 8000
|
command: uv run uvicorn app.api:app --host 0.0.0.0 --port 8000
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- ./files:/app/files
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user