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
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
.venv/
*.egg-info/
dist/
.env
backend/storage/
backend/droplake.db
backend/*.db
*.db-shm
*.db-wal
.DS_Store
+287
View File
@@ -0,0 +1,287 @@
# 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
+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
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dropLake</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="container">
<!-- Logo -->
<div class="logo">
<div class="logo-mark"></div>
<h1>drop<span class="grad">Lake</span></h1>
<p>Encrypted &nbsp;·&nbsp; Ephemeral &nbsp;·&nbsp; No accounts</p>
</div>
<!-- Card -->
<div class="card">
<!-- ═══════════════════════════════════════
VIEW 1 — Upload form
═══════════════════════════════════════ -->
<div id="formView">
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="file">File</button>
<button class="tab-btn" data-tab="text">Text</button>
</div>
<!-- File tab -->
<div id="tab-file" class="tab-content active">
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" tabindex="-1" />
<span class="dz-icon" id="dzIcon">📂</span>
<div class="dz-main" id="dzMain">Drop a file or click to browse</div>
<div class="dz-hint" id="dzHint">1 GB recommended &nbsp;·&nbsp; up to 6 GB accepted</div>
<div class="dz-name" id="dzName"></div>
<div class="dz-size" id="dzSize"></div>
</div>
</div>
<!-- Text tab -->
<div id="tab-text" class="tab-content">
<div class="field">
<label for="textArea">Your text</label>
<textarea id="textArea" placeholder="Paste or type anything here…"></textarea>
</div>
</div>
<!-- Options -->
<div class="field-row">
<div class="field">
<label for="ttlSelect">Expires in</label>
<select id="ttlSelect">
<option value="1">1 hour</option>
<option value="6">6 hours</option>
<option value="24" selected>24 hours</option>
<option value="48">2 days</option>
<option value="72">3 days</option>
</select>
</div>
<div class="field">
<label for="passwordInput">Password <span style="font-weight:400;text-transform:none;letter-spacing:0">(optional)</span></label>
<input id="passwordInput" type="password"
placeholder="Leave blank for URL-only access"
autocomplete="new-password" />
</div>
</div>
<div class="toggle-field">
<label class="toggle-switch">
<input type="checkbox" id="burnToggle" />
<span class="toggle-track"></span>
</label>
<label class="toggle-label" for="burnToggle">Delete after first download</label>
</div>
<!-- Alert (errors) -->
<div id="alertBox" class="alert hidden"></div>
<!-- Upload button -->
<button class="btn" id="uploadBtn">Upload &amp; Encrypt</button>
<!-- Progress -->
<div id="progressWrap" class="progress-wrap mt-2 hidden">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-label" id="progressLbl"></div>
</div>
</div><!-- #formView -->
<!-- ═══════════════════════════════════════
VIEW 2 — Result
═══════════════════════════════════════ -->
<div id="resultView" class="hidden">
<!-- Animated checkmark -->
<div class="success-icon">
<svg viewBox="0 0 52 52">
<circle class="check-circle" cx="26" cy="26" r="24" />
<path class="check-mark" d="M14 26l8 8 16-16" />
</svg>
</div>
<div class="result-title">Share ready</div>
<div class="result-subtitle">Save the link — the decryption key lives only in the URL</div>
<div class="result-label">Share link</div>
<div class="copy-field">
<input id="resultUrl" type="text" readonly />
<button class="copy-btn" id="copyUrlBtn">Copy</button>
</div>
<div id="resultPwWrap" class="hidden">
<div class="result-label mt-1">Password</div>
<div class="copy-field">
<input id="resultPw" type="text" readonly />
<button class="copy-btn" id="copyPwBtn">Copy</button>
</div>
</div>
<div class="alert alert-info mt-1" style="font-size:0.78rem;margin-bottom:0.75rem">
The decryption key is only in this URL and is never stored on the server.
</div>
<button class="share-again-btn" id="shareAgainBtn">+ Share another</button>
</div><!-- #resultView -->
</div><!-- .card -->
</div><!-- .container -->
<footer>
dropLake<span class="sep">·</span>self-hosted<span class="sep">·</span>no tracking
</footer>
<script src="/static/js/upload.js"></script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dropLake — Download</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="container">
<!-- Logo -->
<div class="logo">
<div class="logo-mark"></div>
<h1>drop<span class="grad">Lake</span></h1>
<p>Encrypted &nbsp;·&nbsp; Ephemeral &nbsp;·&nbsp; No accounts</p>
</div>
<!-- Card -->
<div class="card">
<!-- Loading / error state -->
<div id="statusWrap">
<div id="statusMsg" class="alert alert-info">Loading share…</div>
</div>
<!-- Info grid -->
<div id="infoGrid" class="info-grid hidden">
<div class="info-item">
<div class="i-label">Content</div>
<div class="i-value" id="infoType"></div>
</div>
<div class="info-item">
<div class="i-label">Size</div>
<div class="i-value" id="infoSize"></div>
</div>
<div class="info-item">
<div class="i-label">Expires</div>
<div class="i-value" id="infoExpiry"></div>
</div>
<div class="info-item">
<div class="i-label">Burn after read</div>
<div class="i-value" id="infoBurn"></div>
</div>
</div>
<!-- Missing key warning -->
<div id="noKeyWarn" class="alert alert-error hidden">
Decryption key missing. Use the full share URL — it ends with <code style="font-family:var(--mono);font-size:0.85em">#…</code>
</div>
<!-- Password field -->
<div id="pwSection" class="field hidden">
<label for="pwInput">Password required</label>
<input id="pwInput" type="password"
placeholder="Enter the share password"
autocomplete="current-password" />
</div>
<!-- Alert -->
<div id="alertBox" class="alert hidden"></div>
<!-- Download button -->
<button class="btn hidden" id="downloadBtn">Decrypt &amp; Download</button>
<!-- Text output -->
<div id="textSection" class="hidden mt-2">
<div class="result-label" style="margin-bottom:0.5rem">Decrypted text</div>
<div class="text-output" id="textOutput"></div>
<button class="btn btn-outline" id="copyTextBtn">Copy text</button>
</div>
</div><!-- .card -->
</div><!-- .container -->
<footer>
dropLake<span class="sep">·</span>self-hosted<span class="sep">·</span>no tracking
</footer>
<script src="/static/js/download.js"></script>
</body>
</html>
+722
View File
@@ -0,0 +1,722 @@
/* ═══════════════════════════════════════════════════════════
dropLake — UI (glassmorphism · Inter · spring transitions)
═══════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* ── Tokens ─────────────────────────────────────────────── */
:root {
--bg: #05050d;
--glass: rgba(255,255,255,0.028);
--glass-hi: rgba(255,255,255,0.055);
--border: rgba(255,255,255,0.08);
--border-hi: rgba(255,255,255,0.15);
--border-focus: rgba(91,140,247,0.55);
--text: #eaeaf5;
--muted: #6464888;
--muted: #636382;
--accent: #5b8cf7;
--accent-2: #9b6df5;
--success: #24d47a;
--error: #f05858;
--warning: #f5a623;
--glow-a: rgba(91,140,247,0.28);
--glow-s: rgba(36,212,122,0.22);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 20px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--t-fast: 0.14s;
--t-med: 0.26s;
--t-slow: 0.45s;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'SFMono-Regular', 'Fira Code', 'Consolas', monospace;
}
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem 4rem;
overflow-x: hidden;
}
/* Atmospheric glow */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 88% 52% at 50% -4%, rgba(91,140,247,0.11) 0%, transparent 66%),
radial-gradient(ellipse 48% 30% at 88% 88%, rgba(155,109,245,0.07) 0%, transparent 58%),
radial-gradient(ellipse 38% 22% at 8% 72%, rgba(91,140,247,0.04) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* ── Layout ──────────────────────────────────────────────── */
.container {
width: 100%;
max-width: 560px;
position: relative;
z-index: 1;
}
/* ── Logo ────────────────────────────────────────────────── */
.logo {
text-align: center;
margin-bottom: 2.25rem;
animation: fadeDown var(--t-slow) var(--ease) both;
}
.logo-mark {
display: inline-block;
font-size: 1.55rem;
margin-bottom: 0.45rem;
filter: drop-shadow(0 0 14px rgba(91,140,247,0.65));
animation: floatMark 5s ease-in-out infinite;
}
.logo h1 {
font-size: 1.9rem;
font-weight: 700;
letter-spacing: -0.04em;
color: var(--text);
}
.logo h1 .grad {
background: linear-gradient(130deg, var(--accent) 0%, var(--accent-2) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo p {
color: var(--muted);
font-size: 0.8rem;
font-weight: 400;
margin-top: 0.35rem;
letter-spacing: 0.04em;
}
/* ── Card ────────────────────────────────────────────────── */
.card {
background: var(--glass);
border: 1px solid var(--border);
border-top-color: rgba(255,255,255,0.13);
border-radius: var(--r-lg);
padding: 1.75rem;
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 0 0 1px rgba(255,255,255,0.025),
0 32px 64px -8px rgba(0,0,0,0.65),
0 8px 20px rgba(0,0,0,0.4);
animation: cardIn var(--t-slow) var(--ease) 0.08s both;
position: relative;
overflow: hidden;
}
/* Inner top shimmer line */
.card::before {
content: '';
position: absolute;
top: 0; left: 12%; right: 12%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
pointer-events: none;
}
/* ── Tabs ────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 3px;
margin-bottom: 1.5rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.05);
border-radius: var(--r-md);
padding: 4px;
}
.tab-btn {
flex: 1;
background: transparent;
border: none;
color: var(--muted);
padding: 0.42rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
font-family: var(--font);
border-radius: calc(var(--r-md) - 3px);
transition:
color var(--t-fast) var(--ease),
background var(--t-med) var(--ease),
box-shadow var(--t-med) var(--ease);
letter-spacing: 0.01em;
}
.tab-btn.active {
color: var(--text);
background: rgba(255,255,255,0.08);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.tab-btn:not(.active):hover { color: var(--text); }
.tab-content { display: none; }
.tab-content.active {
display: block;
animation: fadeUp var(--t-med) var(--ease) both;
}
/* ── Fields ──────────────────────────────────────────────── */
.field { margin-bottom: 0.95rem; }
.field label {
display: block;
font-size: 0.7rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 0.38rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.field input,
.field textarea,
.field select {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
color: var(--text);
padding: 0.62rem 0.9rem;
border-radius: var(--r-sm);
font-size: 0.88rem;
font-family: var(--font);
outline: none;
transition:
border-color var(--t-fast) var(--ease),
background var(--t-fast) var(--ease),
box-shadow var(--t-fast) var(--ease);
-webkit-appearance: none;
appearance: none;
}
.field input:hover:not(:focus),
.field textarea:hover:not(:focus),
.field select:hover:not(:focus) {
border-color: var(--border-hi);
}
.field input:focus,
.field textarea:focus,
.field select:focus {
border-color: var(--accent);
background: rgba(91,140,247,0.06);
box-shadow: 0 0 0 3px rgba(91,140,247,0.13);
}
.field input::placeholder,
.field textarea::placeholder { color: var(--muted); opacity: 0.6; }
.field textarea {
resize: vertical;
min-height: 130px;
line-height: 1.6;
}
.field select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1l4.5 4.5L10 1' stroke='%23636382' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.85rem center;
padding-right: 2.25rem;
}
.field select option { background: #10101e; color: var(--text); }
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
/* ── Drop zone ───────────────────────────────────────────── */
.drop-zone {
position: relative;
border: 1.5px dashed rgba(255,255,255,0.14);
border-radius: var(--r-md);
padding: 2.75rem 1.25rem;
text-align: center;
cursor: pointer;
color: var(--muted);
margin-bottom: 0.95rem;
background: rgba(255,255,255,0.015);
transition:
border-color var(--t-med) var(--ease),
background var(--t-med) var(--ease),
box-shadow var(--t-med) var(--ease);
overflow: hidden;
}
.drop-zone input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--accent);
border-style: solid;
background: rgba(91,140,247,0.055);
box-shadow: inset 0 0 0 1px rgba(91,140,247,0.1), 0 0 30px rgba(91,140,247,0.07);
}
.drop-zone.has-file {
border-style: solid;
border-color: var(--success);
background: rgba(36,212,122,0.045);
box-shadow: inset 0 0 0 1px rgba(36,212,122,0.1);
}
.dz-icon {
display: block;
font-size: 2.1rem;
margin-bottom: 0.6rem;
line-height: 1;
transition: transform var(--t-med) var(--spring);
}
.drop-zone:hover .dz-icon,
.drop-zone.dragover .dz-icon { transform: scale(1.12) translateY(-3px); }
.dz-main { font-size: 0.88rem; font-weight: 500; }
.dz-hint { font-size: 0.75rem; margin-top: 0.3rem; opacity: 0.6; }
.dz-name { font-weight: 600; word-break: break-all; font-size: 0.88rem; }
.dz-size { font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.65; }
/* ── Toggle switch ───────────────────────────────────────── */
.toggle-field {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.95rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 38px;
height: 21px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0; width: 0; height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.09);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 21px;
cursor: pointer;
transition:
background var(--t-med) var(--ease),
border-color var(--t-med) var(--ease),
box-shadow var(--t-med) var(--ease);
}
.toggle-track::after {
content: '';
position: absolute;
top: 2.5px; left: 2.5px;
width: 14px; height: 14px;
background: var(--muted);
border-radius: 50%;
transition:
transform var(--t-med) var(--spring),
background var(--t-fast) var(--ease);
}
.toggle-switch input:checked + .toggle-track {
background: rgba(91,140,247,0.22);
border-color: var(--accent);
box-shadow: 0 0 10px rgba(91,140,247,0.2);
}
.toggle-switch input:checked + .toggle-track::after {
transform: translateX(17px);
background: var(--accent);
}
.toggle-label {
font-size: 0.875rem;
cursor: pointer;
color: var(--text);
user-select: none;
font-weight: 400;
}
/* ── Divider ─────────────────────────────────────────────── */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border), transparent);
margin: 1.1rem 0;
}
/* ── Button ──────────────────────────────────────────────── */
.btn {
position: relative;
width: 100%;
padding: 0.78rem 1.25rem;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #fff;
border: none;
border-radius: var(--r-sm);
font-size: 0.9rem;
font-weight: 600;
font-family: var(--font);
letter-spacing: 0.02em;
cursor: pointer;
overflow: hidden;
transition:
transform var(--t-fast) var(--spring),
box-shadow var(--t-fast) var(--ease),
opacity var(--t-fast);
}
/* Shimmer sweep on hover */
.btn::after {
content: '';
position: absolute;
top: 0; left: -120%;
width: 55%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transform: skewX(-18deg);
transition: left 0.55s var(--ease);
}
.btn:hover:not(:disabled)::after { left: 160%; }
.btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(91,140,247,0.38), 0 4px 12px rgba(0,0,0,0.35);
}
.btn:active:not(:disabled) {
transform: translateY(0) scale(0.99);
box-shadow: none;
}
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
.btn-outline {
background: transparent;
border: 1px solid rgba(91,140,247,0.5);
color: var(--accent);
}
.btn-outline::after { display: none; }
.btn-outline:hover:not(:disabled) {
transform: translateY(-1px);
background: rgba(91,140,247,0.08);
box-shadow: 0 0 20px rgba(91,140,247,0.16);
border-color: var(--accent);
}
/* ── Progress ────────────────────────────────────────────── */
.progress-wrap { margin-bottom: 0.95rem; }
.progress-bar {
height: 3px;
background: rgba(255,255,255,0.07);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-radius: 3px;
transition: width 0.28s var(--ease);
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0; right: 0;
width: 60px; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
animation: shimmerBar 1.4s var(--ease) infinite;
}
.progress-label {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.4rem;
font-weight: 500;
letter-spacing: 0.02em;
}
/* ── Result box ──────────────────────────────────────────── */
.result {
background: rgba(36,212,122,0.04);
border: 1px solid rgba(36,212,122,0.2);
border-radius: var(--r-md);
padding: 1.25rem 1.25rem 1rem;
margin-top: 1rem;
animation: fadeUp var(--t-med) var(--ease) both;
}
.result-heading {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 700;
color: var(--success);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.result-label {
font-size: 0.68rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 0.32rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.copy-field {
display: flex;
gap: 0.45rem;
align-items: stretch;
margin-bottom: 0.75rem;
}
.copy-field input {
flex: 1;
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.07);
color: var(--text);
padding: 0.48rem 0.75rem;
border-radius: var(--r-sm);
font-size: 0.78rem;
font-family: var(--mono);
outline: none;
min-width: 0;
transition: border-color var(--t-fast) var(--ease);
}
.copy-field input:focus { border-color: var(--border-hi); }
.copy-btn {
padding: 0.48rem 0.9rem;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text);
border-radius: var(--r-sm);
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font);
white-space: nowrap;
flex-shrink: 0;
transition:
background var(--t-fast) var(--ease),
border-color var(--t-fast) var(--ease),
transform var(--t-fast) var(--spring);
letter-spacing: 0.02em;
}
.copy-btn:hover {
background: rgba(255,255,255,0.11);
border-color: rgba(255,255,255,0.2);
transform: translateY(-1px);
}
.copy-btn:active { transform: scale(0.97); }
/* ── Info grid (download page) ───────────────────────────── */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
margin-bottom: 1.4rem;
}
.info-item {
background: rgba(255,255,255,0.025);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 0.85rem 1rem;
transition:
background var(--t-fast) var(--ease),
border-color var(--t-fast) var(--ease),
transform var(--t-fast) var(--spring);
}
.info-item:hover {
background: rgba(255,255,255,0.045);
border-color: var(--border-hi);
transform: translateY(-1px);
}
.info-item .i-label {
font-size: 0.66rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.info-item .i-value {
font-size: 0.875rem;
font-weight: 500;
margin-top: 0.28rem;
word-break: break-word;
}
/* ── Alerts ──────────────────────────────────────────────── */
.alert {
border-radius: var(--r-sm);
padding: 0.7rem 0.95rem;
font-size: 0.83rem;
line-height: 1.5;
margin-bottom: 0.95rem;
border: 1px solid;
font-weight: 400;
}
.alert-warning { background: rgba(245,166,35,0.07); border-color: rgba(245,166,35,0.28); color: var(--warning); }
.alert-error { background: rgba(240,88,88,0.07); border-color: rgba(240,88,88,0.28); color: var(--error); }
.alert-info { background: rgba(91,140,247,0.06); border-color: rgba(91,140,247,0.25); color: var(--accent); }
/* ── Text output ─────────────────────────────────────────── */
.text-output {
background: rgba(0,0,0,0.42);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 1rem 1.1rem;
font-family: var(--mono);
font-size: 0.84rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 360px;
overflow-y: auto;
margin-bottom: 1rem;
color: var(--text);
line-height: 1.68;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
.text-output::-webkit-scrollbar { width: 4px; }
.text-output::-webkit-scrollbar-track { background: transparent; }
.text-output::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
border-radius: 4px;
}
/* ── Footer ──────────────────────────────────────────────── */
footer {
margin-top: 2.5rem;
color: var(--muted);
font-size: 0.72rem;
text-align: center;
opacity: 0.65;
animation: fadeUp var(--t-slow) var(--ease) 0.25s both;
letter-spacing: 0.03em;
}
footer .sep { margin: 0 0.45rem; opacity: 0.35; }
/* ── Keyframes ───────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(18px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes shimmerBar {
0% { opacity: 0; transform: translateX(-40px); }
40% { opacity: 1; }
100% { opacity: 0; transform: translateX(50px); }
}
@keyframes floatMark {
0%,100% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
}
/* ── View crossfade ──────────────────────────────────────── */
@keyframes viewExit {
to { opacity: 0; transform: translateY(-10px); }
}
@keyframes viewEnter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.view-exiting { animation: viewExit 0.26s var(--ease) forwards; }
.view-entering { animation: viewEnter 0.32s var(--ease) both; }
/* ── Result view (standalone success screen) ─────────────── */
#resultView {
display: flex;
flex-direction: column;
gap: 0;
}
.success-icon {
display: flex;
justify-content: center;
margin-bottom: 1.35rem;
}
.success-icon svg { width: 56px; height: 56px; overflow: visible; }
.check-circle {
fill: none;
stroke: var(--success);
stroke-width: 1.8;
stroke-dasharray: 157;
stroke-dashoffset: 157;
animation: drawCircle 0.5s var(--ease) 0.05s forwards;
}
.check-mark {
fill: none;
stroke: var(--success);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 38;
stroke-dashoffset: 38;
animation: drawCheck 0.28s var(--ease) 0.52s forwards;
}
@keyframes drawCircle { to { stroke-dashoffset: 0; } }
@keyframes drawCheck { to { stroke-dashoffset: 0; } }
.result-title {
text-align: center;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
margin-bottom: 0.3rem;
}
.result-subtitle {
text-align: center;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 1.5rem;
}
.share-again-btn {
width: 100%;
margin-top: 0.75rem;
padding: 0.65rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--muted);
font-size: 0.82rem;
font-family: var(--font);
font-weight: 500;
cursor: pointer;
transition:
border-color var(--t-fast) var(--ease),
color var(--t-fast) var(--ease),
background var(--t-fast) var(--ease);
letter-spacing: 0.01em;
}
.share-again-btn:hover {
border-color: var(--border-hi);
color: var(--text);
background: rgba(255,255,255,0.04);
}
/* ── Utilities ───────────────────────────────────────────── */
.hidden { display: none !important; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
+168
View File
@@ -0,0 +1,168 @@
'use strict';
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(n) {
if (!n) return 'unknown size';
if (n < 1024) return n + ' B';
if (n < 1024 ** 2) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 ** 3) return (n / 1024 ** 2).toFixed(1) + ' MB';
return (n / 1024 ** 3).toFixed(2) + ' GB';
}
function formatExpiry(isoStr) {
return new Date(isoStr).toLocaleString();
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
// ── DOM refs ───────────────────────────────────────────────────────────────
const statusWrap = document.getElementById('statusWrap');
const statusMsg = document.getElementById('statusMsg');
const infoGrid = document.getElementById('infoGrid');
const infoType = document.getElementById('infoType');
const infoSize = document.getElementById('infoSize');
const infoExpiry = document.getElementById('infoExpiry');
const infoBurn = document.getElementById('infoBurn');
const alertBox = document.getElementById('alertBox');
const pwSection = document.getElementById('pwSection');
const pwInput = document.getElementById('pwInput');
const downloadBtn = document.getElementById('downloadBtn');
const textSection = document.getElementById('textSection');
const textOutput = document.getElementById('textOutput');
const copyTextBtn = document.getElementById('copyTextBtn');
// ── Init ───────────────────────────────────────────────────────────────────
const shareId = location.pathname.split('/s/')[1];
(async function init() {
if (!shareId) { showError('Invalid share URL.'); return; }
let meta;
try {
const res = await fetch(`/api/share/${shareId}/meta`);
if (!res.ok) {
showError(res.status === 404
? 'This share does not exist or has expired.'
: 'Failed to load share info.');
return;
}
meta = await res.json();
} catch {
showError('Network error. Please check your connection.');
return;
}
if (meta.locked) {
showError(`Share locked — too many failed attempts. Try again in ${meta.lock_remaining}s.`);
return;
}
// Show info grid
statusWrap.classList.add('hidden');
infoGrid.classList.remove('hidden');
infoType.textContent = meta.share_type === 'file'
? (meta.original_filename || 'File') : 'Text snippet';
infoSize.textContent = formatBytes(meta.filesize);
infoExpiry.textContent = formatExpiry(meta.expires_at);
infoBurn.textContent = meta.burn_after_read ? 'Yes — deleted after this download' : 'No';
if (meta.has_password) pwSection.classList.remove('hidden');
downloadBtn.classList.remove('hidden');
// Attach handlers now that we have meta
downloadBtn.addEventListener('click', () => handleDownload(meta));
pwInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleDownload(meta); });
})();
// ── Download ───────────────────────────────────────────────────────────────
async function handleDownload(meta) {
clearAlert();
downloadBtn.disabled = true;
downloadBtn.textContent = 'Downloading…';
try {
const res = await fetch(`/api/share/${shareId}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pwInput.value }),
});
if (res.status === 401) {
const body = await res.json().catch(() => ({}));
showAlert(body.detail || 'Incorrect password.');
pwInput.value = '';
pwInput.focus();
return;
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
showAlert(body.detail || 'Too many attempts. Please wait.');
return;
}
if (!res.ok) {
showAlert('Download failed. The share may have expired.');
return;
}
if (meta.share_type === 'text') {
const text = await res.text();
textSection.classList.remove('hidden');
textOutput.textContent = text;
copyTextBtn.onclick = () => {
navigator.clipboard.writeText(text);
const orig = copyTextBtn.textContent;
copyTextBtn.textContent = 'Copied!';
setTimeout(() => (copyTextBtn.textContent = orig), 1500);
};
downloadBtn.textContent = 'Decrypted';
} else {
const blob = await res.blob();
triggerDownload(blob, meta.original_filename);
downloadBtn.textContent = 'Downloaded ✓';
}
if (meta.burn_after_read) {
showAlert('This share has been deleted — it can no longer be accessed.', 'info');
}
} catch (err) {
showAlert(err.message || 'An unexpected error occurred.');
} finally {
if (downloadBtn.textContent === 'Downloading…') {
downloadBtn.disabled = false;
downloadBtn.textContent = 'Decrypt & Download';
}
}
}
// ── UI helpers ─────────────────────────────────────────────────────────────
function showError(msg) {
statusWrap.classList.remove('hidden');
statusMsg.textContent = msg;
statusMsg.className = 'alert alert-error';
downloadBtn.classList.add('hidden');
}
function showAlert(msg, type = 'error') {
alertBox.className = `alert alert-${type}`;
alertBox.textContent = msg;
alertBox.classList.remove('hidden');
downloadBtn.disabled = false;
downloadBtn.textContent = 'Decrypt & Download';
}
function clearAlert() { alertBox.classList.add('hidden'); }
+242
View File
@@ -0,0 +1,242 @@
'use strict';
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 ** 2) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 ** 3) return (n / 1024 ** 2).toFixed(1) + ' MB';
return (n / 1024 ** 3).toFixed(2) + ' GB';
}
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => (btn.textContent = orig), 1500);
});
}
// ── DOM refs ───────────────────────────────────────────────────────────────
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
const formView = document.getElementById('formView');
const resultView = document.getElementById('resultView');
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const dzIcon = document.getElementById('dzIcon');
const dzMain = document.getElementById('dzMain');
const dzHint = document.getElementById('dzHint');
const dzName = document.getElementById('dzName');
const dzSize = document.getElementById('dzSize');
const textArea = document.getElementById('textArea');
const ttlSelect = document.getElementById('ttlSelect');
const passwordIn = document.getElementById('passwordInput');
const burnToggle = document.getElementById('burnToggle');
const uploadBtn = document.getElementById('uploadBtn');
const progressWrap = document.getElementById('progressWrap');
const progressFill = document.getElementById('progressFill');
const progressLbl = document.getElementById('progressLbl');
const alertBox = document.getElementById('alertBox');
const resultUrl = document.getElementById('resultUrl');
const copyUrlBtn = document.getElementById('copyUrlBtn');
const resultPwWrap = document.getElementById('resultPwWrap');
const resultPw = document.getElementById('resultPw');
const copyPwBtn = document.getElementById('copyPwBtn');
const shareAgainBtn = document.getElementById('shareAgainBtn');
let selectedFile = null;
let activeTab = 'file';
// ── Tabs ───────────────────────────────────────────────────────────────────
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
activeTab = btn.dataset.tab;
tabBtns.forEach(b => b.classList.toggle('active', b === btn));
tabContents.forEach(c => c.classList.toggle('active', c.id === 'tab-' + activeTab));
clearAlert();
});
});
// ── Drop zone ──────────────────────────────────────────────────────────────
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) setFile(fileInput.files[0]);
});
function setFile(file) {
selectedFile = file;
dropZone.classList.add('has-file');
dzIcon.textContent = '📄';
dzMain.textContent = '';
dzHint.textContent = '';
dzName.textContent = file.name;
dzSize.textContent = formatBytes(file.size);
}
// ── Alerts ─────────────────────────────────────────────────────────────────
function showAlert(msg, type = 'error') {
alertBox.className = `alert alert-${type}`;
alertBox.textContent = msg;
alertBox.classList.remove('hidden');
}
function clearAlert() { alertBox.classList.add('hidden'); }
// ── Upload ─────────────────────────────────────────────────────────────────
uploadBtn.addEventListener('click', handleUpload);
async function handleUpload() {
clearAlert();
const isFile = activeTab === 'file';
const password = passwordIn.value.trim();
if (isFile && !selectedFile) { showAlert('Please select a file first.'); return; }
if (!isFile && !textArea.value.trim()) { showAlert('Please enter some text first.'); return; }
uploadBtn.disabled = true;
progressWrap.classList.remove('hidden');
setProgress(0, 'Preparing…');
try {
const form = new FormData();
if (isFile) {
form.append('file', selectedFile, selectedFile.name);
form.append('share_type', 'file');
form.append('original_filename', selectedFile.name);
form.append('mimetype', selectedFile.type || 'application/octet-stream');
} else {
const blob = new Blob([textArea.value], { type: 'text/plain' });
form.append('file', blob, 'text.txt');
form.append('share_type', 'text');
form.append('mimetype', 'text/plain');
}
form.append('ttl_hours', ttlSelect.value);
form.append('burn_after_read', burnToggle.checked);
if (password) form.append('password', password);
setProgress(10, 'Uploading…');
const data = await xhrUpload('/api/upload', form, pct => {
setProgress(10 + Math.round(pct * 0.88), `Uploading… ${Math.round(pct)}%`);
});
setProgress(100, 'Done');
const shareUrl = `${location.origin}/s/${data.share_id}`;
switchToResult(shareUrl, password || null);
} catch (err) {
showAlert(err.message || 'Upload failed. Please try again.');
uploadBtn.disabled = false;
progressWrap.classList.add('hidden');
}
}
function setProgress(pct, label) {
progressFill.style.width = pct + '%';
progressLbl.textContent = label;
}
function xhrUpload(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
let msg = 'Upload failed';
try { msg = JSON.parse(xhr.responseText).detail || msg; } catch {}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
}
// ── View crossfade ─────────────────────────────────────────────────────────
function switchToResult(url, pw) {
resultUrl.value = url;
copyUrlBtn.onclick = () => copyToClipboard(url, copyUrlBtn);
if (pw) {
resultPwWrap.classList.remove('hidden');
resultPw.value = pw;
copyPwBtn.onclick = () => copyToClipboard(pw, copyPwBtn);
} else {
resultPwWrap.classList.add('hidden');
}
// Re-trigger SVG check animation
['.check-circle', '.check-mark'].forEach(sel => {
const el = resultView.querySelector(sel);
el.style.animation = 'none';
el.getBoundingClientRect();
el.style.animation = '';
});
formView.classList.add('view-exiting');
setTimeout(() => {
formView.classList.add('hidden');
formView.classList.remove('view-exiting');
progressWrap.classList.add('hidden');
resultView.classList.remove('hidden');
resultView.classList.add('view-entering');
setTimeout(() => resultView.classList.remove('view-entering'), 350);
}, 260);
}
function switchToForm() {
resultView.classList.add('view-exiting');
setTimeout(() => {
resultView.classList.add('hidden');
resultView.classList.remove('view-exiting');
resetForm();
formView.classList.remove('hidden');
formView.classList.add('view-entering');
setTimeout(() => formView.classList.remove('view-entering'), 350);
}, 260);
}
function resetForm() {
selectedFile = null;
fileInput.value = '';
dropZone.classList.remove('has-file');
dzIcon.textContent = '📂';
dzMain.textContent = 'Drop a file or click to browse';
dzHint.textContent = '1 GB recommended · up to 6 GB accepted';
dzName.textContent = '';
dzSize.textContent = '';
textArea.value = '';
passwordIn.value = '';
burnToggle.checked = false;
ttlSelect.value = '24';
clearAlert();
uploadBtn.disabled = false;
}
shareAgainBtn.addEventListener('click', switchToForm);