From f80aadc3abcbd35f68c89c03eb2d5f18290b6754 Mon Sep 17 00:00:00 2001 From: bot Date: Thu, 2 Apr 2026 16:05:40 +0300 Subject: [PATCH] =?UTF-8?q?initial=20commit=20=E2=80=94=20dropLake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 + README.md | 287 +++++++++++++ backend/.env.example | 6 + backend/config.py | 22 + backend/database.py | 64 +++ backend/main.py | 70 ++++ backend/requirements.txt | 11 + backend/routes/__init__.py | 0 backend/routes/download.py | 148 +++++++ backend/routes/upload.py | 159 ++++++++ backend/utils/__init__.py | 0 backend/utils/cleanup.py | 63 +++ frontend/index.html | 147 +++++++ frontend/s.html | 84 ++++ frontend/static/css/style.css | 722 +++++++++++++++++++++++++++++++++ frontend/static/js/download.js | 168 ++++++++ frontend/static/js/upload.js | 242 +++++++++++ 17 files changed, 2206 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/config.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/download.py create mode 100644 backend/routes/upload.py create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/cleanup.py create mode 100644 frontend/index.html create mode 100644 frontend/s.html create mode 100644 frontend/static/css/style.css create mode 100644 frontend/static/js/download.js create mode 100644 frontend/static/js/upload.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..953c2d5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc8d9e7 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8a0d5d3 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..2b09e49 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..57499cc --- /dev/null +++ b/backend/database.py @@ -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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..18090bb --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e30b57b --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/download.py b/backend/routes/download.py new file mode 100644 index 0000000..cc3efb8 --- /dev/null +++ b/backend/routes/download.py @@ -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, + ) diff --git a/backend/routes/upload.py b/backend/routes/upload.py new file mode 100644 index 0000000..6daea34 --- /dev/null +++ b/backend/routes/upload.py @@ -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, + } diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/utils/cleanup.py b/backend/utils/cleanup.py new file mode 100644 index 0000000..410017c --- /dev/null +++ b/backend/utils/cleanup.py @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e2d9648 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,147 @@ + + + + + + dropLake + + + + + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + 📂 +
Drop a file or click to browse
+
1 GB recommended  ·  up to 6 GB accepted
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + + + + + + + + +
+ + + + + +
+
+ + + + + + diff --git a/frontend/s.html b/frontend/s.html new file mode 100644 index 0000000..db15a17 --- /dev/null +++ b/frontend/s.html @@ -0,0 +1,84 @@ + + + + + + dropLake — Download + + + + + +
+ + + + + +
+ + +
+
Loading share…
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css new file mode 100644 index 0000000..8476b63 --- /dev/null +++ b/frontend/static/css/style.css @@ -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; } diff --git a/frontend/static/js/download.js b/frontend/static/js/download.js new file mode 100644 index 0000000..f0fc407 --- /dev/null +++ b/frontend/static/js/download.js @@ -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'); } diff --git a/frontend/static/js/upload.js b/frontend/static/js/upload.js new file mode 100644 index 0000000..041f0dd --- /dev/null +++ b/frontend/static/js/upload.js @@ -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);