TGStore turns a private Telegram channel into an unlimited, free, end-to-end-self-hosted personal cloud. No S3 bills. No vendor lock-in. Just a bot token, a channel, and code you can read in one sitting.
Why pay AWS for storage when a Telegram bot will hold your files for free?
Telegram gives every bot a globally-cached CDN with multi-GB file support and no egress fees. TGStore is the thin, opinionated layer that turns a private Telegram channel into a Dropbox-class personal cloud — for one person, on hardware you control, with code you can read in one sitting.
┌──────────────┐ multipart ┌────────────┐ sendDocument ┌──────────────────┐
│ Your laptop │ ───────────────▶ │ FastAPI │ ──────────────▶ │ Telegram CDN │
│ (Next.js) │ Bearer JWT │ + SQLAlch │ getFile (1h) │ private channel │
└──────────────┘ ◀─── stream ──── └────────────┘ ◀──── bytes ─── └──────────────────┘
│
▼
┌──────────────┐
│ PostgreSQL │
│ (metadata) │
└──────────────┘
What you get
- 💸 Zero storage cost — Telegram's CDN is the bucket. You only pay for a few KB of metadata per file in Postgres. No S3 bills. No egress fees. No "you've exceeded your free tier" emails.
- 🛡 The bot token never reaches the browser — Telegram's download URLs embed
the bot token and expire in ~1 hour. TGStore regenerates them server-side on
every request and proxies bytes through
/stream. Even a curious DevTools user can never exfiltrate the token. - 🧍 Single-user, self-host, no surprise bills — one admin, one channel, no teams, no sharing ACLs, no per-seat pricing. The auth layer is literally one username + bcrypt password read from env vars.
- 🧰 Boring tech on purpose — Next.js 14, FastAPI, Postgres 16. No
microservices, no message bus, no Kubernetes, no service mesh.
docker compose upand you're done in under five minutes. - 🔍 Full-text search, live stats, soft-delete, recovery — even though the
backend is "just a thin layer," it ships with case-insensitive trigram search,
a stacked-bar storage breakdown by MIME type, and a
deleted_atcolumn so a fat-finger delete is reversible. - 🧪 Tested end-to-end — 6 async integration tests mock the Telegram service
layer, so the CI proves the upload pipeline, the 2 GB cap, and the JWT guard
without ever talking to
api.telegram.org.
What it isn't (so you don't get the wrong idea)
- ❌ Not a team drive. No sharing, no per-user permissions, no comments.
- ❌ Not a Telegram client. The bot is the storage engine; you never see your files in the Telegram app (and shouldn't — that would be a privacy footgun).
- ❌ Not a multi-tenant SaaS. One instance = one user. Run as many as you like, each pointing at its own channel.
- ❌ Not a replacement for S3 for mission-critical workloads. Telegram can lose access to a file (though it's never happened to the author in 18 months). Treat it as a free personal cloud, not a regulated-archive backend.
The system is three layers, all under your control. The animation below traces a real upload end-to-end — request hops, JWT, sendDocument, persistence, response — with the data packet glowing as it moves.
%%{ init: { 'theme': 'dark', 'themeVariables': { 'primaryColor': '#1d4ed8', 'primaryTextColor': '#fff', 'primaryBorderColor': '#3b82f6', 'lineColor': '#60a5fa', 'secondaryColor': '#0b0d10', 'tertiaryColor': '#12151a', 'fontFamily': 'Inter, system-ui' }, 'flowchart': { 'curve': 'basis' } } }%%
flowchart LR
classDef edge fill:#0b0d10,stroke:#3b82f6,color:#e5e7eb,stroke-width:1px;
classDef api fill:#12151a,stroke:#3b82f6,color:#e5e7eb,stroke-width:1.5px;
classDef store fill:#1e3a8a,stroke:#60a5fa,color:#fff,stroke-width:1.5px;
classDef ext fill:#0b0d10,stroke:#26A5E4,color:#fff,stroke-width:2px;
classDef pkt stroke:#22c55e,stroke-width:3px,fill:none,stroke-dasharray:8 4;
User(["👤 You"]):::edge
subgraph Browser["Next.js 14 · App Router"]
MW["middleware.ts<br/>session gate"]:::api
Dash["Dashboard<br/>TopBar · Dropzone · FileList · Stats"]:::api
end
subgraph Backend["FastAPI · Python 3.12"]
Auth["routers/auth.py<br/>POST /auth/login"]:::api
Files["routers/files.py<br/>upload · list · stream · patch · delete"]:::api
TG["services/telegram.py<br/>3x exp-backoff retry"]:::api
end
DB[("PostgreSQL 16<br/>folders · files · soft-delete")]:::store
TG_API(("Telegram Bot API<br/>private channel CDN")):::ext
User -->|HTTPS| MW
MW -->|unauth| Dash
MW -->|authed| Dash
Dash -->|POST /auth/login| Auth
Auth -->|HS256 JWT| Dash
Dash -->|"1. multipart upload, Bearer JWT"| Files
Files -->|"2. size cap less than 2 GB"| Files
Files -->|"3. sendDocument, caption=filename"| TG
TG -->|"4. POST bot TOKEN sendDocument"| TG_API
TG_API -->|"5. bytes stored in CDN"| TG_API
TG -->|"6. file_id, message_id"| Files
Files <-->|"7. INSERT files row"| DB
Files -->|"8. 201 FileResponse"| Dash
Dash -->|"9. invalidate files, stats"| User
linkStyle 0,1,2,3,4 stroke:#60a5fa,stroke-width:1.5px;
linkStyle 5,6,7,8,9 stroke:#22c55e,stroke-width:2.5px,stroke-dasharray:10 4;
🔵 = control plane (auth, navigation) · 🟢 = data plane (the actual bytes)
%%{ init: { 'theme': 'dark' } }%%
graph TB
subgraph FE["frontend/"]
direction LR
L["app/(auth)/login"]:::fe
P["app/page → Dashboard"]:::fe
D["components/Dropzone"]:::fe
L1["components/FileList"]:::fe
S["components/StorageStats"]:::fe
API["lib/api.ts (axios)"]:::fe
AUTH["auth.ts (NextAuth v5)"]:::fe
end
subgraph BE["backend/app/"]
direction LR
RA["routers/auth.py"]:::be
RF["routers/files.py"]:::be
RFO["routers/folders.py"]:::be
MW["middleware/auth.py<br/>require_auth"]:::be
ST["services/telegram.py"]:::be
DB["models/db.py<br/>Folder · File"]:::be
end
DB1[("PostgreSQL")]:::db
TG(("Telegram CDN")):::ext
L --> AUTH
AUTH -->|"POST /auth/login"| RA
P --> D
P --> L1
P --> S
D --> API
L1 --> API
S --> API
API -->|"Bearer JWT"| MW
MW --> RA
MW --> RF
MW --> RFO
RF --> ST
RFO --> ST
RF --> DB
RFO --> DB
ST --> TG
DB --> DB1
classDef fe fill:#0b0d10,stroke:#3b82f6,color:#e5e7eb;
classDef be fill:#12151a,stroke:#22c55e,color:#e5e7eb;
classDef db fill:#1e3a8a,stroke:#60a5fa,color:#fff;
classDef ext fill:#0b0d10,stroke:#26A5E4,color:#fff,stroke-width:2px;
Two tables, no joins across hidden boundaries, soft-delete by convention. The
tg_file_id column is sacred — it is the only durable handle back to your bytes.
%%{ init: { 'theme': 'dark' } }%%
erDiagram
folders ||--o{ folders : "parent_id"
folders ||--o{ files : "folder_id"
folders {
uuid id PK
text name
uuid parent_id FK
text path "materialized"
timestamp created_at
timestamp updated_at
}
files {
uuid id PK
text name "mutable"
text original_name
text mime_type
bigint size_bytes
uuid folder_id FK
text tg_file_id UK "⚠️ sacred"
int tg_message_id
timestamp created_at
timestamp updated_at
timestamp deleted_at "soft-delete"
}
The four interactions that make up 99% of what TGStore does — animated so you can feel the state changes.
① Sign in — first-time setup is a single login
%%{ init: { 'theme': 'dark', 'sequence': { 'actorMargin': 60, 'messageMargin': 40 } } }%%
sequenceDiagram
autonumber
actor U as 👤 You
participant N as Next.js (middleware.ts)
participant L as /login
participant A as NextAuth v5
participant B as FastAPI /auth/login
participant DB as Postgres
U->>N: GET /
N-->>U: 307 → /login?next=/
U->>L: open login form
L->>A: signIn("credentials", user, pass)
A->>B: POST /auth/login
B->>B: bcrypt.compare(ADMIN_PASSWORD)
B->>A: {access_token, expires_in}
A->>A: encrypt → httpOnly cookie<br/>session.apiToken = JWT
A-->>L: session
L-->>U: redirect → /
U->>N: GET / (with cookie)
N-->>U: 200 Dashboard
Note over B,DB: user row is the env file,<br/>Postgres holds files/folders
② Upload a file — drag, drop, done
%%{ init: { 'theme': 'dark' } }%%
flowchart LR
classDef step fill:#0b0d10,stroke:#3b82f6,color:#e5e7eb,stroke-width:1.5px;
classDef ok fill:#22c55e,stroke:#22c55e,color:#0b0d10,stroke-width:2px;
classDef bad fill:#ef4444,stroke:#ef4444,color:#0b0d10,stroke-width:2px;
S1["1. drop file on Dropzone"]:::step
S2["2. client-side size check<br/>≤ 2 GB"]:::step
S3["3. axios POST /files/upload<br/>multipart + Bearer JWT"]:::step
S4["4. require_auth → 401?"]:::step
S5["5. server size check (both<br/>Content-Length & body)"]:::step
S6["6. telegram.send_document<br/>caption = filename"]:::step
S7["7. extract file_id, message_id"]:::step
S8["8. INSERT files row"]:::step
S9["9. 201 FileResponse"]:::step
S10["10. invalidate<br/>['files','stats']"]:::step
R1["❌ 413 File too large"]:::bad
R2["❌ 502 Storage unavailable"]:::bad
S1 --> S2 --> S3 --> S4
S4 -->|"no"| R1
S4 -->|"yes"| S5 --> S6 --> S7 --> S8 --> S9 --> S10
S6 -.->|"3× retry on 5xx"| R2
③ Download a file — proxy never leaks the bot token
%%{ init: { 'theme': 'dark' } }%%
sequenceDiagram
autonumber
actor U as 👤 You
participant FE as FileRow (browser)
participant BE as FastAPI /stream
participant TG as Telegram getFile
participant CDN as Telegram CDN
U->>FE: click ⬇ Download
FE->>BE: GET /files/{id}/stream<br/>Authorization: Bearer JWT
BE->>BE: require_auth → claims
BE->>TG: getFile(file_id)
TG-->>BE: {file_path} (1h URL)
BE->>CDN: GET file_path (follow_redirects)
CDN-->>BE: 64 KB chunks
BE-->>FE: StreamingResponse<br/>Content-Disposition: attachment
FE->>FE: blob → URL.createObjectURL<br/>invisible <a download> click
FE-->>U: 💾 file saved
Note over BE,CDN: the bot token never leaves the backend
④ Browse & manage — search, rename, delete, stats
%%{ init: { 'theme': 'dark' } }%%
stateDiagram-v2
[*] --> Idle
Idle --> Searching: type in TopBar (300 ms debounce)
Searching --> Idle: GET /files?search=…
Idle --> Uploading: drop / pick / press U
Uploading --> Progress: progress card
Progress --> Idle: invalidate ['files','stats']
Progress --> Error: network / 502
Error --> Idle: retry toast
Idle --> Renaming: click ✎ on row
Renaming --> Idle: PATCH /files/{id}
Idle --> Confirming: click 🗑 on row
Confirming --> Idle: DELETE /files/{id} (soft)
Idle --> Previewing: click 👁 (Phase 3)
Previewing --> Idle: open in /stream
| 🎯 Drag-and-drop upload Multi-file, progress cards, 2 GB guard client-side. |
🔐 JWT auth via NextAuth v5 Encrypted httpOnly cookies, edge middleware. |
🗂 Folders (3 levels) Materialized path, server-enforced depth. |
| 🔍 Live search 300 ms debounce, case-insensitive, trigram-indexed. |
📊 Storage stats Stacked bar across Images / Videos / Audio / Docs / Other. |
⬇ Proxied download Bot token never reaches the browser. |
🛡 Soft-deletedeleted_at only — Telegram message kept for recovery. |
⚡ Streaming, not buffering 64 KB chunks, 5-min timeout, follow_redirects. |
🧪 Tested 6 async integration tests, all Telegram calls mocked. |
Three terminals, one bot, zero vendor accounts. The diagram below shows what each command touches so you can keep mental model intact.
%%{ init: { 'theme': 'dark' } }%%
flowchart LR
classDef t fill:#0b0d10,stroke:#3b82f6,color:#e5e7eb;
classDef c fill:#12151a,stroke:#22c55e,color:#0b0d10,font-weight:bold;
classDef a fill:#1e3a8a,stroke:#60a5fa,color:#fff;
T1["terminal 1<br/>🗄 Postgres"]:::t
T2["terminal 2<br/>🐍 FastAPI :8000"]:::t
T3["terminal 3<br/>⚛ Next.js :3000"]:::t
A1["docker compose up -d db"]:::c --> P1[("pgdata :5433")]:::a
A2["uvicorn app.main:app --reload"]:::c --> P2[/"GET /health = 200"/]:::a
A3["npm run dev"]:::c --> P3[/"http://localhost:3000"/]:::a
P1 -.feeds.-> P2
P2 -.http.-> P3
docker compose up -d db
# healthcheck gates the rest of the systemcd backend
python -m venv .venv && source .venv/bin/activate
pip install -e .
cp .env.example .env # fill in BOT_TOKEN, CHAT_ID, JWT_SECRET
alembic upgrade head
uvicorn app.main:app --reloadBackend lives at http://localhost:8000 — OpenAPI docs at /docs.
cd frontend
npm install
cp .env.example .env.local
npm run devDashboard lives at http://localhost:3000.
%%{ init: { 'theme': 'dark' } }%%
sequenceDiagram
autonumber
actor U as 👤 You
participant BF as @BotFather
participant TG as Telegram
participant JD as @JsonDumpBot
participant ENV as backend/.env
U->>BF: /newbot
BF-->>U: BOT_TOKEN
U->>TG: create private channel
U->>TG: add bot as admin
U->>TG: post any message
U->>JD: forward that message
JD-->>U: chat.id = -100xxxxxxxxxx
U->>ENV: BOT_TOKEN=…<br/>CHAT_ID=-100…<br/>JWT_SECRET=$(openssl rand -base64 32)
Note over U,ENV: that's the whole bootstrap
| Var | Required | Default | Purpose |
|---|---|---|---|
BOT_TOKEN |
✅ | — | From @BotFather. Never sent to the browser. |
CHAT_ID |
✅ | — | Private channel id, negative, e.g. -100xxxxxxxxxx. |
DATABASE_URL |
✅ | postgresql+asyncpg://tgstore:tgstore@localhost:5433/tgstore |
Async SQLAlchemy URL. |
DATABASE_URL_SYNC |
✅ | postgresql+psycopg2://… |
Used by Alembic. |
JWT_SECRET |
✅ | — | openssl rand -base64 32. |
JWT_EXPIRE_HOURS |
— | 24 |
Token lifetime. |
ADMIN_USERNAME |
— | admin |
Single-user login. |
ADMIN_PASSWORD |
— | changeme |
Set this in prod. |
ALLOWED_ORIGINS |
— | http://localhost:3000 |
Comma-separated CORS allowlist. |
MAX_UPLOAD_BYTES |
— | 2147483648 |
2 GB — Telegram's Bot API cap. |
ENVIRONMENT |
— | development |
Set to production on Railway/Vercel. Triggers fail-closed checks on dev defaults and mock auth (see Secret rotation). |
| Var | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_API_URL |
✅ | http://localhost:8000 in dev, your Railway URL in prod. |
AUTH_SECRET |
✅ | openssl rand -base64 32 — NextAuth cookie encryption. |
AUTH_URL |
— | http://localhost:3000 in dev. |
NEXT_PUBLIC_FIREBASE_* |
✅ (for Firebase auth) | Web SDK config from Firebase Console → Project Settings → Your apps. Not strictly secret (Firebase web API keys are public-by-design); add App Check + HTTP referrer restrictions in GCP. |
If any of these secrets have lived on a developer disk in plaintext, on a
shared machine, in a backup, or in version control at any point — assume
they are public and rotate them. The backend Settings class refuses to
boot in production with dev defaults (see ENVIRONMENT above), so a
fresh deploy cannot accidentally run with admin_password=changeme or
jwt_secret=dev-secret-change-me.
Order matters — do these in one sitting, in this order. A half-rotated state is more dangerous than not rotating at all (an old, still-trusted secret + a new, partly-deployed one = easy to forget which is which).
| Secret | How to generate |
|---|---|
JWT_SECRET |
openssl rand -hex 32 |
AUTH_SECRET |
openssl rand -hex 32 |
ADMIN_PASSWORD |
openssl rand -base64 24 (or your own 20+ char password) |
BOT_TOKEN |
Telegram → @BotFather → /revoke (picks the bot) → /token (gets the new one) |
DATABASE_URL / DATABASE_URL_SYNC |
Railway → Postgres service → "Data" → "Reset Password" → copy the new connection string into both URLs |
| Firebase SA key | GCP Console → tgstore-a9d23 → IAM & Admin → Service Accounts → firebase-adminsdk-fbsvc@… → Keys → Add Key → Create new key (JSON) |
Store the new values in a password manager note called "TGStore new
secrets" before touching any production system. Do not write them
into backend/.env yet.
rm backend/firebase-sa.json backend/.env # remove the old plaintext copies
# Recreate backend/.env with the NEW values from step 1.
# Use backend/.env.example as a template.
# Prefer FIREBASE_SERVICE_ACCOUNT_JSON over FIREBASE_SERVICE_ACCOUNT_PATH
# so no service-account file lives on disk.
# Add ENVIRONMENT=development (so the safety checks are off locally).Verify locally boots: cd backend && uvicorn app.main:app and hit
http://localhost:8000/health.
Railway → TGStore service → Variables — set:
BOT_TOKEN=<new> # Phase 1.5
DATABASE_URL=<new with rotated password> # Phase 1.4
DATABASE_URL_SYNC=<new with rotated password>
JWT_SECRET=<new> # Phase 1.1
ADMIN_PASSWORD=<new> # Phase 1.3
ALLOWED_ORIGINS=https://tgstore.vercel.app
FIREBASE_SERVICE_ACCOUNT_JSON=<new SA JSON, one line>
FIREBASE_MOCK_AUTH=false
ENVIRONMENT=production # enables fail-closed checks
Vercel → TGStore project → Settings → Environment Variables — set:
AUTH_SECRET=<new> # Phase 1.2
AUTH_URL=https://tgstore.vercel.app
NEXT_PUBLIC_API_URL=<unchanged>
Both deploys auto-redeploy on save. Watch Railway's deploy log for
INFO: Firebase initialized successfully using service account credentials JSON string.
After the new values are live:
- Firebase: GCP Console → Service Accounts → Keys → delete the old
key (the one with the
private_key_idfrom before the rotation). Verify only one key remains. The old private key is now useless. - Telegram: the old token is already revoked by the
/revokestep in Phase 1. Sanity-check:curl -i https://api.telegram.org/bot<old>/getMereturns 404. - Postgres: the old password is already replaced by the "Reset Password" step. Nothing to delete on the DB side.
JWT_SECRET/AUTH_SECRET/ADMIN_PASSWORD: there's no external system to revoke; the old values are simply no longer trusted by the backends. All in-flight sessions are invalidated (every user re-logs in; this is expected).
# Backend health (unauthenticated):
curl -fsS https://<railway>.up.railway.app/health
# Login with the NEW password:
curl -fsS -X POST https://<railway>.up.railway.app/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<new>"}'
# Login FAILS with the OLD password (must return 401):
curl -i -X POST https://<railway>.up.railway.app/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<old>"}'Then in a real browser: sign in on https://tgstore.vercel.app, upload a file, download it, sign out and back in. The full happy-path must work end to end.
The fail-closed checks added in backend/app/core/config.py (see the
production_safety_checks validator) mean that if you forget to set
any one of the production secrets — JWT_SECRET, ADMIN_PASSWORD,
or a Firebase service account — the backend refuses to start when
ENVIRONMENT=production. The deploy will roll back to the last
healthy revision and the Railway log will tell you exactly which env
var is missing. This is intentional: a deploy that boots with
admin_password=changeme is a five-minute account takeover; a deploy
that fails to boot is just a 30-second retry.
All routes below (except /auth/login and /health) require Authorization: Bearer <jwt>.
Full OpenAPI lives at /docs when the backend is running.
%%{ init: { 'theme': 'dark' } }%%
flowchart LR
classDef pub fill:#0b0d10,stroke:#3b82f6,color:#e5e7eb;
classDef priv fill:#12151a,stroke:#22c55e,color:#e5e7eb;
classDef unsafe fill:#3f1d1d,stroke:#ef4444,color:#fecaca;
H["GET /health"]:::pub
L["POST /auth/login"]:::pub
M["GET /auth/me"]:::priv
U["POST /files/upload"]:::priv
LI["GET /files"]:::priv
ST["GET /files/stats"]:::priv
G["GET /files/{id}"]:::priv
S["GET /files/{id}/stream"]:::priv
D["GET /files/{id}/download-url"]:::unsafe
P["PATCH /files/{id}"]:::priv
DE["DELETE /files/{id}"]:::priv
FC["POST /folders"]:::priv
FL["GET /folders"]:::priv
FP["PATCH /folders/{id}"]:::priv
FD["DELETE /folders/{id}"]:::priv
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/health |
— | Liveness probe. |
POST |
/auth/login |
— | {username, password} → {access_token, expires_in}. |
GET |
/auth/me |
🔒 | Current user info. |
POST |
/files/upload |
🔒 | Multipart, folder_id?, 2 GB cap. |
GET |
/files |
🔒 | page, limit≤100, search, folder_id, mime_type, include_deleted. |
GET |
/files/stats |
🔒 | StorageStats grouped by MIME. |
GET |
/files/{id} |
🔒 | Single file metadata. |
GET |
/files/{id}/stream |
🔒 | Use this for browser downloads — proxied bytes. |
GET |
/files/{id}/download-url |
🔒 |
Backend-only. Embeds the bot token — never expose to the client. |
PATCH |
/files/{id} |
🔒 | Rename and/or move. tg_file_id is preserved. |
DELETE |
/files/{id} |
🔒 | Soft-delete. |
POST |
/folders |
🔒 | Body {name, parent_id?}. 3-level depth cap. |
GET |
/folders |
🔒 | ?parent_id=… to list children. |
PATCH |
/folders/{id} |
🔒 | Rename. |
DELETE |
/folders/{id} |
🔒 | Refuses if non-empty. |
The minimal, no-surprises path:
| Service | Why |
|---|---|
| Vercel (frontend) | Edge middleware runs on the edge, NextAuth cookies just work. |
| Railway (backend) | One Dockerfile, healthcheck on /health, persistent env. |
| Neon (Postgres) | Free tier, branching for previews. |
| Telegram (storage) | The only "CDN" you need. |
Pre-flight checklist (from Docs/Ai Instruction.md):
-
BOT_TOKENandCHAT_IDare in Railway env, not in code. -
DATABASE_URLpoints to Neon, not localhost. -
ALLOWED_ORIGINSincludes the Vercel frontend URL. -
JWT_SECRETisopenssl rand -base64 32— notsecret/dev. -
NEXT_PUBLIC_API_URLin Vercel points at the Railway backend URL. -
alembic upgrade headruns on every backend deploy.
%%{ init: { 'theme': 'dark' } }%%
gantt
title TGStore milestones
dateFormat YYYY-MM-DD
axisFormat %b
section Phase 1 (shipped)
Scaffold + Docker compose :done, p1a, 2025-11-01, 7d
FastAPI foundation + auth :done, p1b, after p1a, 7d
Telegram service + retry :done, p1c, after p1b, 5d
Files router (CRUD + stream) :done, p1d, after p1c, 7d
Next.js dashboard + upload :done, p1e, after p1d, 7d
E2E verification :done, p1f, after p1e, 3d
section Phase 2 (next)
Folder polish + breadcrumbs :active, p2a, 2026-06-15, 7d
Move-to-folder UX : p2b, after p2a, 5d
Search v2 (filters, trigram) : p2c, after p2b, 5d
Hard-delete + Telegram purge : p2d, after p2c, 3d
section Phase 3
Inline image preview : p3a, after p2d, 5d
Video / audio / PDF preview : p3b, after p3a, 7d
Share-link (signed URL) : p3c, after p3b, 5d
These rules are baked into the repo. If you fork it, keep them.
%%{ init: { 'theme': 'dark' } }%%
mindmap
root((TGStore<br/>rules))
Security
Bot token never in browser
JWT in httpOnly cookie only
Telegram download URLs never cached
4xx errors never retried
Storage
Single object per file (no chunking)
tg_file_id is sacred and unique
Soft-delete only at this phase
2 GB cap enforced pre-Telegram
Code
async def everywhere
Pydantic v2 only
Alembic for all schema changes
Server Components by default
What we don't do
No S3 / local disk
No multi-user
No payment logic
No requests library
TGStore/
├── backend/ # FastAPI · Python 3.12+
│ ├── app/
│ │ ├── main.py # app factory, CORS, router mount
│ │ ├── core/ # config (pydantic-settings) + async DB
│ │ ├── middleware/ # JWT: create_access_token, require_auth
│ │ ├── routers/ # auth · files · folders
│ │ ├── models/ # SQLAlchemy ORM + Pydantic schemas
│ │ ├── services/ # telegram.py (the only place that hits api.telegram.org)
│ │ └── utils/ # helpers, MIME grouping
│ ├── alembic/ # 0001_initial.py
│ ├── tests/ # 6 async integration tests, Telegram mocked
│ ├── pyproject.toml
│ └── .env.example
├── frontend/ # Next.js 14 · App Router · TypeScript strict
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx # → Dashboard
│ │ ├── providers.tsx # QueryClient + SessionProvider
│ │ ├── globals.css
│ │ ├── (auth)/login/ # /login
│ │ └── api/auth/[…] # NextAuth route handlers
│ ├── components/ # Dashboard · TopBar · Dropzone · FileList · FileRow · StorageStats · ApiAuthBridge
│ ├── lib/ # api.ts (axios) · format.ts
│ ├── types/ # mirrors backend Pydantic
│ ├── auth.ts # NextAuth v5 config
│ ├── middleware.ts # edge session gate
│ └── tailwind.config.ts
├── Docs/
│ ├── PRD.md # product spec
│ ├── TRD.md # technical spec
│ ├── APP FLOW.md # user journey
│ └── Ai Instruction.md # the rules
├── docker-compose.yml # Postgres 16 on host 5433
└── README.md # you are here
# backend (Telegram calls mocked, in-memory SQLite for speed)
cd backend
pytest -v
# 6 tests, ~1.5s:
# ✓ test_health_is_unauthenticated
# ✓ test_login_success_returns_jwt
# ✓ test_login_failure_returns_401
# ✓ test_protected_endpoint_requires_auth
# ✓ test_upload_2gb_cap_is_enforced_before_telegram
# ✓ test_upload_happy_path_persists_metadataDocs/PRD.md— product spec, scope, success criteriaDocs/TRD.md— technical spec, data model, endpoint contracts- [
Docs/APP FLOW.md](Docs/APP FLOW.md) — every user journey, every error state - [
Docs/Ai Instruction.md](Docs/Ai Instruction.md) — the non-negotiables