288 lines
11 KiB
Markdown
288 lines
11 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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):
|
|
1. Clone repo to `/opt/droplake`
|
|
2. Set up virtualenv, install deps, configure `.env`
|
|
3. Create `droplake.service` systemd unit
|
|
4. Configure Nginx reverse proxy with:
|
|
- `client_max_body_size 6g`
|
|
- Proxy timeouts sized for large uploads
|
|
- Security headers (CSP, X-Frame-Options, HSTS)
|
|
5. Obtain TLS cert via Certbot (Porkbun DNS challenge or HTTP-01)
|
|
6. Point domain, test, done
|
|
|
|
---
|
|
|
|
## Roadmap
|
|
|
|
### Done
|
|
- [x] FastAPI backend with SQLite
|
|
- [x] AES-256-GCM server-side encryption, 64 MB chunked streaming
|
|
- [x] Argon2id password hashing
|
|
- [x] Per-share brute-force lockout + security event log
|
|
- [x] Background cleanup scheduler (expired + burn-after-read)
|
|
- [x] 10-char base62 share IDs
|
|
- [x] Mandatory TTL (1 h / 6 h / 24 h / 2 d / 3 d) — default 24 h
|
|
- [x] Burn after read
|
|
- [x] Dark glassmorphism UI (Inter font, spring transitions, animated checkmark)
|
|
- [x] Upload view → result view crossfade (form replaced by result, not stacked)
|
|
- [x] Live upload progress bar (XHR)
|
|
- [x] File drop zone + text tab
|
|
- [x] 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 `.env` hardening notes
|
|
- [ ] Optional: rate-limit upload endpoint per IP
|
|
- [ ] Optional: admin view of active shares via SSH + sqlite3 CLI
|