initial commit — dropLake

This commit is contained in:
bot
2026-04-02 16:05:40 +03:00
commit f80aadc3ab
17 changed files with 2206 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
STORAGE_PATH=./storage
DATABASE_URL=sqlite:///./droplake.db
MAX_UPLOAD_BYTES=6442450944
MAX_FAILED_ATTEMPTS=10
LOCKOUT_MINUTES=15
CLEANUP_INTERVAL_MINUTES=30
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
storage_path: str = str(Path(__file__).parent / "storage")
database_url: str = "sqlite:///./droplake.db"
# 6 GB hard limit — frontend warns at 1 GB
max_upload_bytes: int = 6 * 1024 * 1024 * 1024
# Brute-force protection
max_failed_attempts: int = 10
lockout_minutes: int = 15
# Background cleanup
cleanup_interval_minutes: int = 30
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime
from sqlalchemy import create_engine, Column, String, Integer, Boolean, DateTime, Text
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
class Share(Base):
__tablename__ = "shares"
id = Column(String(36), primary_key=True) # UUID4
share_type = Column(String(10), nullable=False) # 'file' | 'text'
# Metadata provided by client before encryption
original_filename = Column(String(512), nullable=True)
mimetype = Column(String(128), nullable=True)
filesize = Column(Integer, nullable=True) # bytes of the encrypted blob
# Storage — blob stored under a random name, never the original
storage_path = Column(String(512), nullable=False)
# AES-256-GCM key (hex-encoded, 64 chars). Server generated, stored at rest.
encryption_key = Column(String(64), nullable=False)
# Access control
password_hash = Column(String(512), nullable=True) # Argon2id; NULL = no password
# Lifecycle — all datetimes are naive UTC
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
burn_after_read = Column(Boolean, default=False)
downloaded = Column(Boolean, default=False)
# Brute-force protection
failed_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
class SecurityEvent(Base):
__tablename__ = "security_events"
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.utcnow)
event_type = Column(String(50), nullable=False)
share_id = Column(String(36), nullable=True)
ip_hash = Column(String(64), nullable=True) # SHA-256 of IP — no raw IPs
details = Column(Text, nullable=True)
+70
View File
@@ -0,0 +1,70 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from config import settings
from database import Base, engine
from routes.download import router as download_router
from routes.upload import router as upload_router
from utils.cleanup import start_scheduler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s%(message)s",
)
FRONTEND = Path(__file__).parent.parent / "frontend"
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(settings.storage_path, exist_ok=True)
Base.metadata.create_all(bind=engine)
scheduler = start_scheduler()
yield
scheduler.shutdown(wait=False)
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(
title="dropLake",
lifespan=lifespan,
docs_url=None, # no Swagger UI exposed
redoc_url=None,
openapi_url=None,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# API routes
app.include_router(upload_router, prefix="/api")
app.include_router(download_router, prefix="/api")
# Static assets (CSS, JS)
app.mount("/static", StaticFiles(directory=str(FRONTEND / "static")), name="static")
@app.get("/")
async def serve_index():
return FileResponse(str(FRONTEND / "index.html"))
@app.get("/s/{share_id}")
async def serve_share_page(share_id: str):
# The JS on this page reads share_id from the URL path and the key from the fragment
return FileResponse(str(FRONTEND / "s.html"))
@app.get("/health")
async def health():
return {"status": "ok"}
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.6
pydantic-settings==2.7.0
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
argon2-cffi==23.1.0
python-multipart==0.0.18
aiofiles==24.1.0
python-dotenv==1.0.1
apscheduler==3.10.4
slowapi==0.1.9
cryptography==44.0.2
View File
+148
View File
@@ -0,0 +1,148 @@
import hashlib
import struct
from datetime import datetime, timedelta
from pathlib import Path
from typing import Generator
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from database import Share, SecurityEvent, get_db
router = APIRouter()
_ph = PasswordHasher()
# ── Helpers ────────────────────────────────────────────────────────────────
def _hash_ip(ip: str) -> str:
return hashlib.sha256(ip.encode()).hexdigest()
def _client_ip(request: Request) -> str:
fwd = request.headers.get('X-Forwarded-For')
if fwd:
return fwd.split(',')[0].strip()
return request.client.host if request.client else 'unknown'
def _live_share(share_id: str, db: Session) -> Share:
share = db.query(Share).filter(Share.id == share_id).first()
if not share:
raise HTTPException(404, 'Share not found')
if datetime.utcnow() > share.expires_at:
raise HTTPException(404, 'Share not found')
if share.burn_after_read and share.downloaded:
raise HTTPException(404, 'Share not found')
return share
def _decrypt_stream(path: Path, key_hex: str) -> Generator[bytes, None, None]:
"""Yield decrypted plaintext chunks from a chunked AES-256-GCM file."""
aesgcm = AESGCM(bytes.fromhex(key_hex))
with open(path, 'rb') as f:
chunk_count = struct.unpack('>I', f.read(4))[0]
for _ in range(chunk_count):
length = struct.unpack('>I', f.read(4))[0]
payload = f.read(length)
nonce, ct = payload[:12], payload[12:]
yield aesgcm.decrypt(nonce, ct, None)
# ── Routes ─────────────────────────────────────────────────────────────────
@router.get('/share/{share_id}/meta')
async def share_meta(share_id: str, db: Session = Depends(get_db)):
share = _live_share(share_id, db)
locked, lock_remaining = False, 0
if share.locked_until and datetime.utcnow() < share.locked_until:
locked = True
lock_remaining = int((share.locked_until - datetime.utcnow()).total_seconds())
return {
'share_type' : share.share_type,
'original_filename': share.original_filename,
'mimetype' : share.mimetype,
'filesize' : share.filesize,
'expires_at' : share.expires_at.isoformat() + 'Z',
'burn_after_read' : share.burn_after_read,
'has_password' : share.password_hash is not None,
'locked' : locked,
'lock_remaining' : lock_remaining,
}
class PasswordBody(BaseModel):
password: str = ''
@router.post('/share/{share_id}/download')
async def download_share(
share_id: str,
body: PasswordBody,
request: Request,
db: Session = Depends(get_db),
):
share = _live_share(share_id, db)
now = datetime.utcnow()
ip_hash = _hash_ip(_client_ip(request))
# Lockout check
if share.locked_until and now < share.locked_until:
secs = int((share.locked_until - now).total_seconds())
raise HTTPException(429, f'Too many failed attempts. Try again in {secs}s.')
# Password check
if share.password_hash:
if not body.password:
raise HTTPException(401, 'Password required')
try:
_ph.verify(share.password_hash, body.password)
except (VerifyMismatchError, VerificationError):
share.failed_attempts = (share.failed_attempts or 0) + 1
db.add(SecurityEvent(
event_type='failed_attempt', share_id=share_id,
ip_hash=ip_hash, details=f'attempt #{share.failed_attempts}',
))
if share.failed_attempts >= settings.max_failed_attempts:
share.locked_until = now + timedelta(minutes=settings.lockout_minutes)
db.add(SecurityEvent(
event_type='share_locked', share_id=share_id, ip_hash=ip_hash,
details=f'locked {settings.lockout_minutes}m after {share.failed_attempts} attempts',
))
db.commit()
raise HTTPException(401, 'Incorrect password')
share.failed_attempts = 0
share.locked_until = None
blob_path = Path(settings.storage_path) / share.storage_path
if not blob_path.exists():
raise HTTPException(404, 'Share not found')
share.downloaded = True
db.add(SecurityEvent(event_type='share_accessed', share_id=share_id))
db.commit()
key_hex = share.encryption_key
burn = share.burn_after_read
def _stream():
try:
yield from _decrypt_stream(blob_path, key_hex)
finally:
if burn:
blob_path.unlink(missing_ok=True)
headers = {}
if share.original_filename:
safe = share.original_filename.encode('ascii', 'replace').decode()
headers['Content-Disposition'] = f'attachment; filename="{safe}"'
return StreamingResponse(
_stream(),
media_type=share.mimetype or 'application/octet-stream',
headers=headers,
)
+159
View File
@@ -0,0 +1,159 @@
import os
import secrets
import string
import struct
from datetime import datetime, timedelta
from pathlib import Path
from argon2 import PasswordHasher
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from sqlalchemy.orm import Session
from config import settings
from database import Share, SecurityEvent, get_db
router = APIRouter()
_ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16)
# ── Constants ──────────────────────────────────────────────────────────────
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MB plaintext per chunk
BASE62 = string.ascii_letters + string.digits
VALID_TTL = {1, 6, 24, 48, 72} # hours; 72h max — ephemeral sharing, not storage
# ── Helpers ────────────────────────────────────────────────────────────────
def _share_id() -> str:
"""10-character base62 ID — ~59 bits of entropy, brute-force safe with rate limiting."""
return ''.join(secrets.choice(BASE62) for _ in range(10))
def _sanitize_filename(name: str) -> str:
safe = Path(name).name
safe = ''.join(c for c in safe if c.isprintable() and c not in r'\/:*?"<>|')
return safe[:255] or 'unnamed'
async def _encrypt_to_disk(upload: UploadFile, out_path: Path, key: bytes) -> int:
"""
Stream-encrypt upload into chunked AES-256-GCM format.
File layout:
[4B uint32 BE — chunk count]
Per chunk:
[4B uint32 BE — len(nonce + ciphertext)]
[12B nonce]
[N B ciphertext + 16B GCM tag]
Returns plaintext byte count.
"""
aesgcm = AESGCM(key)
chunk_count = 0
plaintext_bytes = 0
buf = bytearray()
try:
with open(out_path, 'wb') as f:
f.write(b'\x00\x00\x00\x00') # placeholder for chunk count
while True:
data = await upload.read(1024 * 1024) # 1 MB reads
if not data:
break
plaintext_bytes += len(data)
if plaintext_bytes > settings.max_upload_bytes:
raise HTTPException(413, 'Upload exceeds size limit')
buf.extend(data)
while len(buf) >= CHUNK_SIZE:
chunk = bytes(buf[:CHUNK_SIZE])
del buf[:CHUNK_SIZE]
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, chunk, None)
payload = nonce + ct
f.write(struct.pack('>I', len(payload)))
f.write(payload)
chunk_count += 1
# Final partial chunk
if buf:
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, bytes(buf), None)
payload = nonce + ct
f.write(struct.pack('>I', len(payload)))
f.write(payload)
chunk_count += 1
# Write real chunk count
f.seek(0)
f.write(struct.pack('>I', chunk_count))
except HTTPException:
out_path.unlink(missing_ok=True)
raise
except Exception:
out_path.unlink(missing_ok=True)
raise HTTPException(500, 'Encryption failed during upload')
return plaintext_bytes
# ── Route ──────────────────────────────────────────────────────────────────
@router.post('/upload')
async def upload_share(
request: Request,
file: UploadFile = File(...),
share_type: str = Form(...),
original_filename: str = Form(None),
mimetype: str = Form(None),
ttl_hours: int = Form(24),
burn_after_read: bool = Form(False),
password: str = Form(None),
db: Session = Depends(get_db),
):
if share_type not in ('file', 'text'):
raise HTTPException(400, 'Invalid share_type')
if ttl_hours not in VALID_TTL:
raise HTTPException(400, 'Invalid TTL value')
if password and len(password) > 128:
raise HTTPException(400, 'Password too long')
if original_filename:
original_filename = _sanitize_filename(original_filename)
if mimetype:
mimetype = mimetype[:128]
# Generate key and storage path
key = os.urandom(32) # AES-256 key
share_id = _share_id()
blob_name = secrets.token_hex(16) # random storage filename
blob_path = Path(settings.storage_path) / blob_name
Path(settings.storage_path).mkdir(parents=True, exist_ok=True)
plaintext_bytes = await _encrypt_to_disk(file, blob_path, key)
password_hash = _ph.hash(password) if password else None
now = datetime.utcnow()
share = Share(
id = share_id,
share_type = share_type,
original_filename= original_filename,
mimetype = mimetype,
filesize = plaintext_bytes,
storage_path = blob_name,
encryption_key = key.hex(),
password_hash = password_hash,
created_at = now,
expires_at = now + timedelta(hours=ttl_hours),
burn_after_read = burn_after_read,
)
db.add(share)
db.add(SecurityEvent(event_type='share_created', share_id=share_id))
db.commit()
return {
'share_id' : share_id,
'expires_at': share.expires_at.isoformat() + 'Z',
'has_password': password_hash is not None,
}
View File
+63
View File
@@ -0,0 +1,63 @@
import logging
from datetime import datetime
from pathlib import Path
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy.orm import Session
from config import settings
from database import SecurityEvent, SessionLocal, Share
log = logging.getLogger("droplake.cleanup")
def purge_expired():
db: Session = SessionLocal()
try:
now = datetime.utcnow()
# Expired shares
expired = db.query(Share).filter(Share.expires_at < now).all()
# Burn-after-read shares that have been downloaded
burnt = (
db.query(Share)
.filter(Share.burn_after_read.is_(True), Share.downloaded.is_(True))
.all()
)
# Deduplicate by id
seen = {}
for s in expired + burnt:
seen[s.id] = s
to_remove = list(seen.values())
for share in to_remove:
blob = Path(settings.storage_path) / share.storage_path
blob.unlink(missing_ok=True)
db.add(SecurityEvent(event_type="share_cleaned", share_id=share.id))
db.delete(share)
if to_remove:
db.commit()
log.info("Cleanup: removed %d share(s)", len(to_remove))
except Exception as exc:
log.error("Cleanup error: %s", exc)
db.rollback()
finally:
db.close()
def start_scheduler() -> BackgroundScheduler:
scheduler = BackgroundScheduler(daemon=True)
scheduler.add_job(
purge_expired,
"interval",
minutes=settings.cleanup_interval_minutes,
id="cleanup",
replace_existing=True,
misfire_grace_time=60,
)
scheduler.start()
log.info("Cleanup scheduler started (every %dm)", settings.cleanup_interval_minutes)
return scheduler