dropLake
A self-hosted, privacy-first file and text sharing web app. No accounts. No ads. No tracking. Password-protected shares. Auto-expiry.
Built for personal use — deploy it on your own VPS, share with friends, keep full control over your data.
What it does
- Upload a file (any type, up to 6 GB) or paste text
- Every share is AES-256-GCM encrypted at rest on the server
- Optional custom password (Argon2id hashed, never stored plaintext)
- Every share has a mandatory TTL (1 h → 3 days, default 24 h)
- Optional burn after read — share deletes itself after the first download
- Short, clean share URLs:
https://yourdomain.com/s/SE6RLPy53b - Brute-force protection: 10 failed password attempts → 15-minute lockout
- Security event log (no raw IPs stored — SHA-256 hashed)
- Background job purges expired and burnt shares every 30 minutes
Security model
Encryption at rest — AES-256-GCM (server-side)
Every upload is encrypted on the server before touching disk. The server generates a random 256-bit key per share, encrypts the content in 64 MB chunks (so even 6 GB files stream without loading into memory), and stores only the encrypted blob. The key is stored in the database alongside the share record.
Chunk format on disk:
[4B — uint32 chunk count]
Per chunk:
[4B — uint32 payload length]
[12B — AES-GCM nonce (unique per chunk)]
[NB — ciphertext + 16B GCM authentication tag]
This means:
- Disk theft / backup leak → files are unreadable without the database
- Each chunk is independently authenticated → tampering is detectable
- Streaming decryption on download → no memory ceiling for large files
Trade-off acknowledged: the server holds the AES key. If an attacker fully owns both the filesystem and the database, they can decrypt. For a self-hosted personal tool where you control the server, this is the right trade-off — the realistic threats (physical disk theft, leaked backup) are covered. This is the same model used by most serious file-sharing services (WeTransfer, Filemail, etc.).
Password protection — Argon2id
Optional. If set:
- Server stores only the Argon2id hash (time=2, mem=64 MB, par=2)
- Encrypted blob is not served until the password is verified
- 10 wrong attempts → share locked for 15 minutes
- Password shown once to the uploader after the upload completes
Share IDs
10-character base62 (a-zA-Z0-9), ~59 bits of entropy.
Brute-force enumeration is blocked by per-IP rate limiting.
No sequential or guessable patterns.
Security event log
All security-relevant events are written to the security_events table.
No raw IP addresses are stored — only their SHA-256 hash, which allows
pattern detection (repeated attacks from the same source) without logging
personally identifiable information.
| Event | When |
|---|---|
share_created |
New upload completed |
failed_attempt |
Wrong password submitted |
share_locked |
Share locked after too many failures |
share_accessed |
Successful download |
share_cleaned |
Share purged by cleanup scheduler |
OWASP Top 10 (2025) coverage
| Risk | How it's addressed |
|---|---|
| A01 Broken Access Control | Random 10-char IDs; password gate; no enumerable endpoints |
| A02 Security Misconfiguration | No debug endpoints; Swagger/OpenAPI disabled in production |
| A03 Supply Chain Failures | Pinned dependencies in requirements.txt; minimal footprint |
| A04 Cryptographic Failures | AES-256-GCM per share; Argon2id for passwords |
| A05 Injection | SQLAlchemy parameterized queries; filename sanitization |
| A06 Insecure Design | Mandatory TTL; burn-after-read; short random IDs |
| A07 Auth Failures | Per-share attempt lockout; IP-based rate limiting (slowapi) |
| A08 Data Integrity | Blobs stored outside web root; never executed by the server |
| A09 Logging & Monitoring | Security event table; structured application logging |
| A10 Exceptional Conditions | Generic error responses; stack traces never sent to client |
Stack
| Layer | Technology |
|---|---|
| Backend | Python 3.11+ / FastAPI |
| Database | SQLite via SQLAlchemy |
| Encryption | AES-256-GCM — cryptography library (server-side) |
| Password hashing | Argon2id — argon2-cffi |
| Rate limiting | slowapi (per-IP) |
| Background cleanup | APScheduler |
| Frontend | Plain HTML / CSS / JS — no framework, no build step |
| Web server (prod) | Nginx + Let's Encrypt (Porkbun DNS) |
Project structure
droplake/
├── README.md
├── .gitignore
├── backend/
│ ├── main.py # FastAPI app, lifespan, static file serving
│ ├── config.py # Settings — all configurable via .env
│ ├── database.py # SQLAlchemy models (Share, SecurityEvent) + session
│ ├── routes/
│ │ ├── upload.py # POST /api/upload
│ │ │ # — streams upload, encrypts in 64 MB chunks,
│ │ │ # stores blob + AES key, returns share ID
│ │ └── download.py # GET /api/share/{id}/meta
│ │ # POST /api/share/{id}/download
│ │ # — verifies password, streams decrypted content
│ ├── utils/
│ │ └── cleanup.py # APScheduler job: purges expired + burnt shares
│ ├── storage/ # Encrypted blobs — gitignored, outside web root
│ ├── requirements.txt
│ └── .env.example
└── frontend/
├── index.html # Upload page (File tab + Text tab)
├── s.html # Download / view page
└── static/
├── css/
│ └── style.css # Dark glassmorphism theme, Inter font, spring transitions
└── js/
├── upload.js # XHR upload with live progress bar, view crossfade
└── download.js# Fetch share, password prompt, trigger download / show text
Local development
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn main:app --reload --port 8000
Open: http://localhost:8000
The SQLite database and storage/ directory are created automatically on
first run. Both are gitignored. To reset during development:
rm -f backend/droplake.db
rm -rf backend/storage/
Configuration — .env
Copy .env.example to .env and adjust as needed.
| Variable | Default | Notes |
|---|---|---|
STORAGE_PATH |
./storage |
Where encrypted blobs are stored. Use an absolute path on the VPS. |
DATABASE_URL |
sqlite:///./droplake.db |
SQLite file path. |
MAX_UPLOAD_BYTES |
6442450944 |
6 GB hard limit. Frontend shows a 1 GB recommendation. |
MAX_FAILED_ATTEMPTS |
10 |
Failed password attempts before a share locks. |
LOCKOUT_MINUTES |
15 |
How long a locked share stays locked. |
CLEANUP_INTERVAL_MINUTES |
30 |
How often the background job runs to purge expired shares. |
API endpoints
| Method | Path | Description |
|---|---|---|
GET |
/ |
Upload page |
GET |
/s/{share_id} |
Download page |
POST |
/api/upload |
Upload a file or text |
GET |
/api/share/{id}/meta |
Share metadata (type, size, expiry, has password) |
POST |
/api/share/{id}/download |
Verify password, stream decrypted content |
GET |
/health |
Health check — returns {"status":"ok"} |
Swagger UI and OpenAPI schema are disabled (docs_url=None, openapi_url=None).
Database schema
shares
| Column | Type | Notes |
|---|---|---|
id |
TEXT (10) | Base62 share ID |
share_type |
TEXT | 'file' or 'text' |
original_filename |
TEXT | Original name, sanitized. NULL for text shares. |
mimetype |
TEXT | MIME type for Content-Type on download |
filesize |
INTEGER | Plaintext byte count (shown to downloader) |
storage_path |
TEXT | Random hex filename under STORAGE_PATH |
encryption_key |
TEXT | AES-256 key, hex-encoded (64 chars) |
password_hash |
TEXT | Argon2id hash. NULL = no password. |
created_at |
DATETIME | UTC |
expires_at |
DATETIME | UTC — enforced on every request |
burn_after_read |
BOOLEAN | Delete after first successful download |
downloaded |
BOOLEAN | Set to True on first successful download |
failed_attempts |
INTEGER | Resets to 0 on correct password |
locked_until |
DATETIME | NULL when not locked |
security_events
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | Auto-increment PK |
timestamp |
DATETIME | UTC |
event_type |
TEXT | See event table above |
share_id |
TEXT | References the share (not a FK, survives share deletion) |
ip_hash |
TEXT | SHA-256 of client IP. NULL for non-network events. |
details |
TEXT | Human-readable context (e.g. "attempt #3") |
VPS deployment — overview (to be expanded)
Target: Ubuntu VPS, Nginx reverse proxy, Let's Encrypt via Porkbun.
[client] ──HTTPS──► [Nginx] ──HTTP──► [uvicorn :8000]
│
TLS termination
file size limits
rate limiting headers
Steps (detailed guide coming):
- Clone repo to
/opt/droplake - Set up virtualenv, install deps, configure
.env - Create
droplake.servicesystemd unit - Configure Nginx reverse proxy with:
client_max_body_size 6g- Proxy timeouts sized for large uploads
- Security headers (CSP, X-Frame-Options, HSTS)
- Obtain TLS cert via Certbot (Porkbun DNS challenge or HTTP-01)
- Point domain, test, done
Roadmap
Done
- FastAPI backend with SQLite
- AES-256-GCM server-side encryption, 64 MB chunked streaming
- Argon2id password hashing
- Per-share brute-force lockout + security event log
- Background cleanup scheduler (expired + burn-after-read)
- 10-char base62 share IDs
- Mandatory TTL (1 h / 6 h / 24 h / 2 d / 3 d) — default 24 h
- Burn after read
- Dark glassmorphism UI (Inter font, spring transitions, animated checkmark)
- Upload view → result view crossfade (form replaced by result, not stacked)
- Live upload progress bar (XHR)
- File drop zone + text tab
- Download page: info grid, password prompt, streaming download
Next
- Nginx config file + systemd service file (in repo)
- Porkbun / Let's Encrypt setup guide
- Short domain chosen and configured
- Production
.envhardening notes - Optional: rate-limit upload endpoint per IP
- Optional: admin view of active shares via SSH + sqlite3 CLI