Cloud version of tup: turn Telegram chats, groups, and channels into cloud storage drives — served as a web app with a React single-page frontend, FastAPI backend, PostgreSQL, and Redis, all in Docker.
cp .env.example .env # fill in your Telegram credentials (same as tup's ~/.tup/.env)
./tup-cloud.sh start # builds and starts everything, waits for health
open http://localhost:8080./tup-cloud.sh also provides stop, rebuild [service], purge, status,
and logs [service]. To bring an existing desktop-tup index into the cloud,
run ./scripts/import-registry.sh.
Log in via Telegram: open the bot, press Start, send /login, and enter
the code it returns on the login page. You can log in only if you are a member
of at least one registered drive chat, and you see only those drives. The
first person to log in becomes admin. Sessions last 30 days of inactivity.
┌────────────────────────────────── docker compose ──────────────────────────────────┐
│ │
│ Browser ──────────► nginx (frontend container) │
│ React SPA • serves the built SPA (immutable hashed assets) │
│ • proxies /api and /ws to the backend │
│ │ │
│ ▼ │
│ ┌────────────────────── backend (FastAPI + Telethon) ─────────────────────────┐ │
│ │ │ │
│ │ auth (Telegram OTP) vfs ops (ls/mkdir/mv/cp/trash) uploads (spool) │ │
│ │ membership guard markdown save + versioning transfers queue │ │
│ │ streaming (HTTP Range) observer (/Other pipeline) cache cleaner │ │
│ │ WebSocket hub ◄──────────── Redis pub/sub ("tup:events") │ │
│ │ │ │
│ │ ONE Telethon MTProto client = the only file transport │ │
│ └───────┬──────────────────────┬───────────────────────┬──────────────────────┘ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ volumes │ │
│ │ system of │ │ sessions, │ │ MTProto session, │ │
│ │ record │ │ pub/sub, RL │ │ file cache, spool │ │
│ └──────────────┘ └──────────────┘ └───────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘
│
▼
madly important:
Telegram itself is the actual file storage
The single most important idea, inherited from tup: files live in Telegram messages; everything else is an index.
-
Every drive is one chat/group/channel. Every file is one message whose caption carries a machine-readable protocol block:
📁 `/docs/report.pdf` 🔗 SHA256: <64-hex> <optional user caption with #tags> #vfs #docs -
PostgreSQL's
vfs_indexis a cache of that truth, not the truth itself. The captions alone can rebuild the database: a/.Trash/…path means "in the Recycle Bin"; several messages with the same path are versions of one file (newest message wins, older ones are history). -
Consequence: the desktop tup CLI/GUI, this web app, and any future frontend interoperate on the same chats with no coordination — they all read/write the same caption protocol. (
TECH-DEBT.mdtracks which cloud features the desktop apps should adopt.)
A single long-lived Telethon client (bot-token login) does all byte
transfer: uploads (2 GB cap), downloads, Range streaming, thumbnails, caption
edits, deletes, and the login-code DMs. The HTTP Bot API is used only for
cheap metadata (getChat, getChatMember). Transfers are serialized through
one semaphore because Telegram throttles parallel bot transfers anyway. The
MTProto session persists in a Docker volume, so restarts don't re-login.
No passwords. Two ways in, both ending in the same session:
- Bot-initiated (primary) — DM
/loginto the bot. It verifies your group membership live, then replies with an 8-character single-use code (HMAC-hashed in Redis, 5-min TTL, per-user rate limit). Enter it on the login page. Works even for brand-new group members, because you message the bot first (bots cannot DM strangers). - Web-initiated — enter your @username; the bot DMs you a 6-digit code bound to a server-side challenge. Requires that the bot could already message you.
Sessions are JWT access tokens (60 min) plus rotating refresh tokens in Redis
(30 days, 30-second grace window so parallel tabs don't log each other out) —
both in HttpOnly; SameSite=Lax cookies. Cookies are load-bearing:
<video>/<img> tags and the WebSocket authenticate with them, which
header-based tokens cannot do.
There are no roles to manage (beyond one admin): you can use tup-cloud iff
Telegram says you're a member of a drive chat, checked live via
getChatMember and cached 5 minutes in Redis. Every VFS, file, upload, and
WebSocket endpoint filters by that membership; users see exactly the drives
whose groups they belong to. Admins see all drives and can register/remove
drives and block users.
Every mutation — upload finished, index changed, observer progress, transfer
progress — is published to a single Redis pub/sub channel (tup:events) and
fanned out over one WebSocket per client, filtered per connection by drive
membership. The frontend reacts to events (e.g. index-changed triggers a
quiet refetch), so two browsers watching the same drive stay in sync without
polling.
A Telethon NewMessage handler watches every registered chat:
- A file with a tup caption (sent by the CLI, GUI, or another tup-cloud) is indexed at its declared path.
- Any other file lands in
/Otherthrough a staged pipeline —detected → analyzing → indexed(orskipped/failed) — with every stage persisted and streamed live to the Observer feed in the UI. Observed files are markedorigin: observedand use atg:<doc_id>pseudo-hash (nothing is downloaded to hash). - Private messages to the bot are handled here too:
/login(OTP issuing),/start(onboarding), and shared contacts (enables phone-based lookup).
GET /api/files/{id}/stream honors HTTP Range: byte offsets are aligned to
MTProto's 1 MiB chunk grid and proxied straight from Telegram via
iter_download. That gives seekable <video>/<audio> playback with no full
download (Safari's bytes=0-1 probe, mid-file seeks — all served as proper
206 Partial Content). Because MTProto tops out at a few MB/s, high-bitrate
files offer ⚡ Cache for fast playback: the server pulls the file to its
disk cache in the background; cached files serve at disk speed. A sweeper
deletes cache entries idle past CACHE_TTL_MINUTES (serving a file refreshes
its clock).
Markdown files are first-class: a full-page Tiptap editor (Mantine's editor
component) reads/writes real .md messages through
POST /api/files/text. Since every save posts a new Telegram message, the
replaced message is kept as a version snapshot (file_versions table,
capped at 20 per file) — History lists them, clicking one loads it into the
editor, saving restores it. The same save-through-server pipeline is the
planned mount point for Collabora Online.
Deleting a file moves it under the hidden /.Trash/ prefix and rewrites its
Telegram caption to that path — so the bin's state survives in Telegram
itself (see: rebuildable database). The bin is a sidebar node listing files by
original location; Restore strips the prefix, and Empty/Delete-forever purges
the message and every version message.
Browser → backend (multipart into a spool volume, progress via XHR events),
then backend → Telegram (MTProto with progress callbacks published to the
event bus). The transfers dock shows both hops live. Failures keep the spooled
file and land in failed_registry for retry; same-folder SHA-256 duplicates
are skips, never errors (tup's dedup rule).
React 18 + TypeScript + zustand + Mantine v7 (default dark theme, Tabler
icons). The SPA fetches the whole drive index in one request and derives
everything client-side as pure functions — folder tree, listings, recursive
ls -R view, #tag filtering, the Recycle Bin view — mirroring the desktop
GUI's "one query per refresh" design. Drag & drop (OS files and folders in,
internal move/copy), full keyboard shortcuts, context menus, Mantine modals
for all dialogs, and media previews in a fullscreen modal.
| 🧱 Storage | Telegram messages are the files; Postgres only indexes them |
| 🔁 Rebuildable | Captions encode path, hash, tags, trash state, versions |
| 📡 Transport | One Telethon MTProto client; 2 GB/file; serialized transfers |
| 🔐 Auth | /login OTP via the bot; JWT + rotating refresh in HttpOnly cookies |
| 🛂 Access | Live group membership = drive visibility, enforced everywhere |
| 🔄 Live UI | Redis pub/sub → per-user-filtered WebSocket events |
| 👁 Observer | Group uploads auto-indexed into /Other with a live pipeline feed |
| 🎬 Playback | Range-streaming (seek without downloading) + optional server cache |
| 📝 Editing | Full-page markdown editor with per-save version history |
| 🗑 Safety | Recycle Bin with restore; purge deletes messages + versions |
| 🧹 Hygiene | TTL cache autocleaner; secrets scrubbed from logs |
tup-cloud/
├── docker-compose.yml # postgres 16 · redis 7 · backend · frontend(nginx)
├── tup-cloud.sh # start / stop / rebuild / purge / status / logs
├── scripts/import-registry.sh # one-shot import of ~/.tup/registry.db
├── backend/app/
│ ├── main.py # app factory, lifespan wiring, WebSocket endpoint
│ ├── config.py # env-only settings
│ ├── db.py models.py # async SQLAlchemy + additive migrations
│ ├── security.py # JWT, refresh rotation, OTP codes, rate limits
│ ├── membership.py # group-membership ACL (Redis-cached)
│ ├── telegram.py # THE Telethon client + caption protocol
│ ├── transfers.py # spool → MTProto queue with live progress
│ ├── observer.py # NewMessage → /Other pipeline, /login, /start
│ ├── events.py # Redis pub/sub hub
│ ├── cleaner.py # cache TTL sweeper
│ └── routers/ # auth · drives · vfs (incl. trash) · uploads ·
│ # files (stream/thumb/text/versions) · observer
└── frontend/src/
├── store.ts # zustand state + pure derivations (tree/rows/trash)
├── api.ts ws.ts # fetch wrapper (auto-refresh) + WebSocket client
├── media.tsx dialogs.tsx# type→icon mapping · Mantine modal helpers
└── components/ # Explorer(AppShell) · FileList · Sidebar ·
# MarkdownEditor(page) · Preview · Transfers ·
# ObserverFeed · Login
See PLAN.md for the phased build history, CLAUDE.md for contributor/agent
notes, and TECH-DEBT.md for the cloud ↔ desktop parity ledger.