NitinBot001 commited on
Commit
353a253
Β·
verified Β·
1 Parent(s): 6fa5dcc

Upload 12 files

Browse files
Files changed (12) hide show
  1. .env +16 -0
  2. .gitignore +3 -0
  3. Dockerfile +37 -0
  4. README.md +423 -1
  5. db.py +94 -0
  6. frontend.html +874 -0
  7. main.py +296 -0
  8. requirements.txt +7 -0
  9. server.py +27 -0
  10. tg.py +282 -0
  11. tokens.txt +1 -0
  12. vercel.json +15 -0
.env ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Telegram credentials (from https://my.telegram.org)
2
+ API_ID=15022711
3
+ API_HASH=51225833a1b50c15c6c39071ea567e48
4
+
5
+ # Target channel (must be a supergroup/channel your bots are admin of)
6
+ # Use negative ID for supergroups: e.g. -1001234567890
7
+ CHANNEL_ID=-1003739341703
8
+
9
+ # MongoDB Atlas connection string
10
+ # Get this from: Atlas β†’ Connect β†’ Drivers β†’ Python
11
+ MONGODB_URI=mongodb+srv://nitinbhujwa:nitinbhujwa@tgstorage.thhklhw.mongodb.net/?appName=tgstorage
12
+ MONGO_DB_NAME=tgstorage
13
+
14
+ # API authentication key for this server
15
+ ADMIN_API_KEY=
16
+ # BASE_URL=
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .env
2
+ __pycache__
3
+ .gitignore
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE 1
6
+ ENV PYTHONUNBUFFERED 1
7
+ ENV FLASK_APP=server.py
8
+ ENV FLASK_ENV=production
9
+
10
+ # Set the working directory in the container
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies required for some Python packages
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ gcc \
16
+ python3-dev \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Copy requirements first to leverage Docker cache
20
+ COPY requirements.txt .
21
+
22
+ # Install Python dependencies
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
+
25
+ # Create a non-root user and switch to it
26
+ RUN groupadd -r appuser && useradd -r -g appuser appuser
27
+ RUN chown -R appuser:appuser /app
28
+ USER appuser
29
+
30
+ # Copy the rest of the application
31
+ COPY --chown=appuser:appuser . .
32
+
33
+ # Expose the port the app runs on
34
+ EXPOSE 8082
35
+
36
+ # Command to run the application
37
+ CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "4", "--threads", "2", "server:app"]
README.md CHANGED
@@ -7,4 +7,426 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ <div align="center">
11
+
12
+ # πŸ“‘ TG Storage
13
+
14
+ **Infinite file storage powered by Telegram β€” with a clean REST API, public CDN URLs, and a built-in test UI.**
15
+
16
+ [![Python](https://img.shields.io/badge/Python-3.10+-blue?logo=python&logoColor=white)](https://python.org)
17
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.111+-green?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
18
+ [![MongoDB](https://img.shields.io/badge/MongoDB-Atlas-brightgreen?logo=mongodb&logoColor=white)](https://www.mongodb.com/atlas)
19
+ [![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
20
+ [![GitHub](https://img.shields.io/badge/GitHub-NitinBot001%2FTG--Storage-black?logo=github)](https://github.com/NitinBot001/TG-Storage)
21
+
22
+ </div>
23
+
24
+ ---
25
+
26
+ ## ✨ What is TG Storage?
27
+
28
+ TG Storage turns any Telegram channel into a **free, unlimited cloud storage backend**. You upload files through a REST API β€” they get stored in your Telegram channel β€” and you get back a **permanent, public CDN URL** to share anywhere.
29
+
30
+ No S3. No GCS. No storage bills.
31
+
32
+ ### Key features
33
+
34
+ - πŸš€ **REST API** β€” Upload, download, list, and delete files via HTTP
35
+ - πŸ”— **Public CDN URLs** β€” Shareable links with no auth required (`/cdn/your-path`)
36
+ - 🏷️ **Custom paths** β€” Assign vanity paths like `/cdn/images/logo.png` or `/cdn/avatar.jpg`
37
+ - πŸ€– **Multi-bot pool** β€” Add multiple bot tokens to spread Telegram rate limits
38
+ - 🧠 **MongoDB Atlas** β€” File metadata stored in the cloud, zero local state
39
+ - πŸ–₯️ **Built-in UI** β€” Drop-in browser interface to test everything at `/`
40
+ - ⚑ **Pure httpx** β€” No telegram library dependencies, raw Bot API calls only
41
+
42
+ ---
43
+
44
+ ## πŸ“ Project Structure
45
+
46
+ ```
47
+ TG-Storage/
48
+ β”œβ”€β”€ main.py # FastAPI app β€” all routes & lifespan
49
+ β”œβ”€β”€ db.py # MongoDB Atlas async layer (Motor)
50
+ β”œβ”€β”€ tg.py # Telegram Bot API client (pure httpx)
51
+ β”œβ”€β”€ server.py # Uvicorn entry point
52
+ β”œβ”€β”€ frontend.html # Built-in browser test UI
53
+ β”œβ”€β”€ requirements.txt # Python dependencies
54
+ β”œβ”€β”€ vercel.json # Vercel deployment config
55
+ β”œβ”€β”€ .env.example # Environment variable template
56
+ └── tokens.txt # (you create) one bot token per line
57
+ ```
58
+
59
+ ---
60
+
61
+ ## πŸ› οΈ Setup & Installation
62
+
63
+ ### 1. Clone the repo
64
+
65
+ ```bash
66
+ git clone https://github.com/NitinBot001/TG-Storage.git
67
+ cd TG-Storage
68
+ ```
69
+
70
+ ### 2. Install dependencies
71
+
72
+ ```bash
73
+ pip install -r requirements.txt
74
+ ```
75
+
76
+ ### 3. Create your Telegram bot(s)
77
+
78
+ 1. Open [@BotFather](https://t.me/BotFather) on Telegram
79
+ 2. Send `/newbot` and follow the prompts
80
+ 3. Copy the token (looks like `1234567890:AAExampleTokenHere`)
81
+ 4. Repeat for as many bots as you want (more bots = higher upload throughput)
82
+
83
+ ### 4. Set up your Telegram channel
84
+
85
+ 1. Create a **private channel** in Telegram
86
+ 2. Add all your bots as **Administrators** with permission to **post messages**
87
+ 3. Get the channel ID β€” forward any message from the channel to [@JsonDumpBot](https://t.me/JsonDumpBot) and look for `"chat": { "id": -1001234567890 }`
88
+
89
+ ### 5. Create `tokens.txt`
90
+
91
+ ```
92
+ # tokens.txt β€” one bot token per line, lines starting with # are ignored
93
+ 1234567890:AAExampleToken1Here
94
+ 9876543210:AAExampleToken2Here
95
+ ```
96
+
97
+ ### 6. Configure environment
98
+
99
+ Copy `.env.example` to `.env` and fill in your values:
100
+
101
+ ```bash
102
+ cp .env.example .env
103
+ ```
104
+
105
+ ```env
106
+ # Telegram
107
+ CHANNEL_ID=-1001234567890
108
+
109
+ # MongoDB Atlas (get from: Atlas β†’ Connect β†’ Drivers β†’ Python)
110
+ MONGODB_URI=mongodb+srv://<user>:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority
111
+ MONGO_DB_NAME=tgstorage
112
+
113
+ # API auth key β€” clients must send this in X-API-Key header
114
+ ADMIN_API_KEY=your-secret-key-here
115
+
116
+ # Public base URL β€” used to build CDN links
117
+ # Local dev: http://localhost:8082
118
+ # Production: https://your-vercel-app.vercel.app
119
+ BASE_URL=http://localhost:8082
120
+ ```
121
+
122
+ ### 7. Run the server
123
+
124
+ ```bash
125
+ python server.py
126
+ ```
127
+
128
+ Server starts at **http://localhost:8082**
129
+ Open it in your browser to access the built-in test UI.
130
+
131
+ ---
132
+
133
+ ## 🌐 API Reference
134
+
135
+ All endpoints except `/` and `/cdn/*` require the header:
136
+ ```
137
+ X-API-Key: your-secret-key-here
138
+ ```
139
+
140
+ ### `POST /upload` β€” Upload a file
141
+
142
+ **Form fields:**
143
+
144
+ | Field | Type | Required | Description |
145
+ |-------|------|----------|-------------|
146
+ | `file` | file | βœ… | The file to upload (any format) |
147
+ | `custom_path` | string | ❌ | Vanity CDN path, e.g. `images/logo.png` |
148
+
149
+ **Example:**
150
+ ```bash
151
+ # Upload with auto-generated ID
152
+ curl -X POST http://localhost:8082/upload \
153
+ -H "X-API-Key: your-key" \
154
+ -F "file=@photo.jpg"
155
+
156
+ # Upload with custom path
157
+ curl -X POST http://localhost:8082/upload \
158
+ -H "X-API-Key: your-key" \
159
+ -F "file=@logo.png" \
160
+ -F "custom_path=brand/logo.png"
161
+ ```
162
+
163
+ **Response:**
164
+ ```json
165
+ {
166
+ "file_id": "550e8400-e29b-41d4-a716-446655440000",
167
+ "filename": "logo.png",
168
+ "mime_type": "image/png",
169
+ "size_bytes": 20480,
170
+ "custom_path": "brand/logo.png",
171
+ "public_url": "http://localhost:8082/cdn/brand/logo.png",
172
+ "cdn_url_by_id": "http://localhost:8082/cdn/550e8400-...",
173
+ "cdn_url_by_path": "http://localhost:8082/cdn/brand/logo.png",
174
+ "uploaded_at": "2025-01-01T12:00:00"
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ### `GET /cdn/{path}` β€” Public CDN URL *(no auth)*
181
+
182
+ Works with both the UUID file_id and any assigned custom path:
183
+
184
+ ```
185
+ GET /cdn/550e8400-e29b-41d4-a716-446655440000 ← by file_id
186
+ GET /cdn/logo.png ← by custom_path
187
+ GET /cdn/images/avatar.jpg ← by nested custom_path
188
+ ```
189
+
190
+ Files are served `inline` β€” images, PDFs, and videos render directly in the browser.
191
+
192
+ ---
193
+
194
+ ### `GET /file/{file_id}` β€” Download *(auth required)*
195
+
196
+ Forces a file download (`Content-Disposition: attachment`).
197
+
198
+ ```bash
199
+ curl -H "X-API-Key: your-key" \
200
+ http://localhost:8082/file/550e8400-... \
201
+ -o downloaded.jpg
202
+ ```
203
+
204
+ ---
205
+
206
+ ### `GET /files` β€” List all files
207
+
208
+ ```bash
209
+ curl -H "X-API-Key: your-key" \
210
+ "http://localhost:8082/files?limit=50&offset=0"
211
+ ```
212
+
213
+ **Response:**
214
+ ```json
215
+ {
216
+ "total": 42,
217
+ "limit": 50,
218
+ "offset": 0,
219
+ "files": [
220
+ {
221
+ "file_id": "...",
222
+ "filename": "photo.jpg",
223
+ "mime_type": "image/jpeg",
224
+ "size_bytes": 204800,
225
+ "custom_path": "photos/summer.jpg",
226
+ "public_url": "http://localhost:8082/cdn/photos/summer.jpg",
227
+ "uploaded_at": "2025-01-01T12:00:00"
228
+ }
229
+ ]
230
+ }
231
+ ```
232
+
233
+ ---
234
+
235
+ ### `DELETE /file/{file_id}` β€” Delete a record
236
+
237
+ Removes the metadata from MongoDB. The Telegram message remains in the channel.
238
+
239
+ ```bash
240
+ curl -X DELETE -H "X-API-Key: your-key" \
241
+ http://localhost:8082/file/550e8400-...
242
+ ```
243
+
244
+ ---
245
+
246
+ ### `GET /health` β€” Health check
247
+
248
+ ```bash
249
+ curl http://localhost:8082/health
250
+ ```
251
+
252
+ ```json
253
+ {
254
+ "status": "ok",
255
+ "timestamp": "2025-01-01T12:00:00",
256
+ "total_files": 42,
257
+ "base_url": "http://localhost:8082"
258
+ }
259
+ ```
260
+
261
+ ---
262
+
263
+ ## πŸš€ Deploy to Vercel
264
+
265
+ Vercel runs Python serverless functions β€” perfect for this API.
266
+
267
+ ### Prerequisites
268
+
269
+ - A [Vercel account](https://vercel.com) (free tier works)
270
+ - The repo pushed to GitHub at `https://github.com/NitinBot001/TG-Storage`
271
+
272
+ ---
273
+
274
+ ### Step 1 β€” Add `tokens.txt` to the repo *(or use env var)*
275
+
276
+ > ⚠️ **Do not commit real bot tokens to a public repo.**
277
+ >
278
+ > Instead, encode your tokens as a single environment variable:
279
+
280
+ On your machine, run:
281
+ ```bash
282
+ # Join tokens with a newline, then base64-encode
283
+ python -c "
284
+ import base64
285
+ tokens = '1234567890:TokenOne\n9876543210:TokenTwo'
286
+ print(base64.b64encode(tokens.encode()).decode())
287
+ "
288
+ ```
289
+
290
+ Copy the output β€” you'll add it as `TOKENS_B64` in Vercel. Then update `tg.py` to decode it (see Step 4).
291
+
292
+ ---
293
+
294
+ ### Step 2 β€” Import project in Vercel
295
+
296
+ 1. Go to [vercel.com/new](https://vercel.com/new)
297
+ 2. Click **"Import Git Repository"**
298
+ 3. Select `NitinBot001/TG-Storage`
299
+ 4. Framework preset: **Other**
300
+ 5. Click **Deploy** (it will fail β€” that's fine, we need to add env vars first)
301
+
302
+ ---
303
+
304
+ ### Step 3 β€” Add environment variables
305
+
306
+ In your Vercel project β†’ **Settings β†’ Environment Variables**, add:
307
+
308
+ | Name | Value |
309
+ |------|-------|
310
+ | `CHANNEL_ID` | `-1001234567890` |
311
+ | `MONGODB_URI` | `mongodb+srv://...` |
312
+ | `MONGO_DB_NAME` | `tgstorage` |
313
+ | `ADMIN_API_KEY` | `your-secret-key` |
314
+ | `BASE_URL` | `https://your-app.vercel.app` |
315
+ | `TOKENS_B64` | *(base64 string from Step 1)* |
316
+
317
+ ---
318
+
319
+ ### Step 4 β€” Update `tg.py` to read `TOKENS_B64`
320
+
321
+ Replace the `_tokens_path()` function in `tg.py` with this loader that checks for the env var first:
322
+
323
+ ```python
324
+ import base64, tempfile, os
325
+
326
+ def _get_tokens() -> list[str]:
327
+ """Read tokens from TOKENS_B64 env var (Vercel) or tokens.txt (local)."""
328
+ b64 = os.getenv("TOKENS_B64", "").strip()
329
+ if b64:
330
+ decoded = base64.b64decode(b64).decode("utf-8")
331
+ return [l.strip() for l in decoded.splitlines() if l.strip() and not l.startswith("#")]
332
+
333
+ # Fallback to file
334
+ for candidate in [Path(__file__).parent / "tokens.txt", Path(os.getcwd()) / "tokens.txt"]:
335
+ if candidate.exists():
336
+ return [l.strip() for l in candidate.read_text(encoding="utf-8").splitlines()
337
+ if l.strip() and not l.startswith("#")]
338
+ raise FileNotFoundError("No tokens found. Set TOKENS_B64 env var or create tokens.txt.")
339
+ ```
340
+
341
+ Then in `init_bot_pool()` replace:
342
+ ```python
343
+ raw_tokens = [...] # the old file-reading block
344
+ ```
345
+ with:
346
+ ```python
347
+ raw_tokens = _get_tokens()
348
+ ```
349
+
350
+ ---
351
+
352
+ ### Step 5 β€” The `vercel.json` is already included
353
+
354
+ ```json
355
+ {
356
+ "version": 2,
357
+ "builds": [{ "src": "main.py", "use": "@vercel/python" }],
358
+ "routes": [{ "src": "/(.*)", "dest": "main.py" }]
359
+ }
360
+ ```
361
+
362
+ ---
363
+
364
+ ### Step 6 β€” Redeploy
365
+
366
+ Push your changes to GitHub:
367
+ ```bash
368
+ git add tg.py
369
+ git commit -m "feat: support TOKENS_B64 for Vercel deployment"
370
+ git push
371
+ ```
372
+
373
+ Vercel auto-deploys on every push. Your API will be live at:
374
+ ```
375
+ https://tg-storage-xxxx.vercel.app
376
+ ```
377
+
378
+ Update `BASE_URL` in Vercel env vars to match your actual deployment URL.
379
+
380
+ ---
381
+
382
+ ### ⚠️ Vercel Limitations
383
+
384
+ | Limitation | Impact |
385
+ |------------|--------|
386
+ | **10s function timeout** (Hobby plan) | Large file uploads may time out. Upgrade to Pro (60s) or use a VPS. |
387
+ | **4.5 MB request body limit** | Files larger than 4.5 MB cannot be uploaded via Vercel's edge. Use a VPS for large files. |
388
+ | **Serverless = stateless** | The bot pool reinitializes on every cold start. `tokens.txt` won't persist β€” use `TOKENS_B64`. |
389
+ | **No persistent filesystem** | MongoDB Atlas handles all state β€” this is fine. |
390
+
391
+ For large files or heavy usage, deploy on a **VPS (Railway, Render, DigitalOcean)** instead β€” run `python server.py` directly with no changes needed.
392
+
393
+ ---
394
+
395
+ ## 🐳 Self-host with Docker *(optional)*
396
+
397
+ ```dockerfile
398
+ FROM python:3.11-slim
399
+ WORKDIR /app
400
+ COPY . .
401
+ RUN pip install -r requirements.txt
402
+ EXPOSE 8082
403
+ CMD ["python", "server.py"]
404
+ ```
405
+
406
+ ```bash
407
+ docker build -t tg-storage .
408
+ docker run -p 8082:8082 --env-file .env -v $(pwd)/tokens.txt:/app/tokens.txt tg-storage
409
+ ```
410
+
411
+ ---
412
+
413
+ ## πŸ”’ Security Notes
414
+
415
+ - Never expose `ADMIN_API_KEY` publicly β€” it controls all file operations
416
+ - The `/cdn/*` endpoint is intentionally public β€” anyone with the URL can access the file
417
+ - Bot tokens in `tokens.txt` should never be committed to a public repo
418
+ - MongoDB URI contains credentials β€” keep it in `.env` / Vercel environment variables only
419
+
420
+ ---
421
+
422
+ ## πŸ“œ License
423
+
424
+ MIT β€” free to use, modify, and deploy.
425
+
426
+ ---
427
+
428
+ <div align="center">
429
+ Built with ❀️ using FastAPI + Telegram Bot API + MongoDB Atlas
430
+ <br/>
431
+ <a href="https://github.com/NitinBot001/TG-Storage">⭐ Star on GitHub</a>
432
+ </div>
db.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ db.py β€” Async MongoDB Atlas metadata store using Motor.
3
+ Collection: tgstorage.files
4
+ """
5
+
6
+ import os
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from motor.motor_asyncio import AsyncIOMotorClient
11
+ from pymongo import DESCENDING
12
+
13
+ MONGO_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
14
+ DB_NAME = os.getenv("MONGO_DB_NAME", "tgstorage")
15
+
16
+ _client: Optional[AsyncIOMotorClient] = None
17
+
18
+
19
+ def _get_collection():
20
+ global _client
21
+ if _client is None:
22
+ _client = AsyncIOMotorClient(MONGO_URI)
23
+ return _client[DB_NAME]["files"]
24
+
25
+
26
+ async def init_db():
27
+ """Ensure indexes exist."""
28
+ col = _get_collection()
29
+ await col.create_index("file_id", unique=True)
30
+ await col.create_index([("uploaded_at", DESCENDING)])
31
+ # sparse=True so documents without custom_path don't conflict
32
+ await col.create_index("custom_path", unique=True, sparse=True)
33
+
34
+
35
+ # ──────────────────────────────────────────────────
36
+ # CRUD
37
+ # ──────────────────────────────────────────────────
38
+
39
+ async def save_file_record(
40
+ *,
41
+ file_id: str,
42
+ filename: str,
43
+ mime_type: str,
44
+ size: int,
45
+ tg_message_id: int,
46
+ tg_file_id: str | None,
47
+ public_url: str,
48
+ custom_path: str | None = None,
49
+ ):
50
+ col = _get_collection()
51
+ doc = {
52
+ "file_id": file_id,
53
+ "filename": filename,
54
+ "mime_type": mime_type,
55
+ "size_bytes": size,
56
+ "tg_message_id": tg_message_id,
57
+ "tg_file_id": tg_file_id,
58
+ "public_url": public_url,
59
+ "uploaded_at": datetime.utcnow().isoformat(),
60
+ }
61
+ if custom_path:
62
+ doc["custom_path"] = custom_path
63
+ await col.insert_one(doc)
64
+
65
+
66
+ async def get_file_record(file_id: str) -> dict | None:
67
+ col = _get_collection()
68
+ return await col.find_one({"file_id": file_id}, {"_id": 0})
69
+
70
+
71
+ async def get_file_by_custom_path(custom_path: str) -> dict | None:
72
+ col = _get_collection()
73
+ return await col.find_one({"custom_path": custom_path}, {"_id": 0})
74
+
75
+
76
+ async def list_file_records(limit: int = 50, offset: int = 0) -> list[dict]:
77
+ col = _get_collection()
78
+ cursor = (
79
+ col.find({}, {"_id": 0})
80
+ .sort("uploaded_at", DESCENDING)
81
+ .skip(offset)
82
+ .limit(limit)
83
+ )
84
+ return await cursor.to_list(length=limit)
85
+
86
+
87
+ async def delete_file_record(file_id: str):
88
+ col = _get_collection()
89
+ await col.delete_one({"file_id": file_id})
90
+
91
+
92
+ async def count_files() -> int:
93
+ col = _get_collection()
94
+ return await col.count_documents({})
frontend.html ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>TG Storage β€” File Vault</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Syne:wght@400;700;800&display=swap" rel="stylesheet" />
8
+ <style>
9
+ :root {
10
+ --bg: #050810;
11
+ --surface: #0b1120;
12
+ --border: #1a2a45;
13
+ --accent: #00d4ff;
14
+ --accent2: #7b2fff;
15
+ --danger: #ff3c6e;
16
+ --success: #00ffb3;
17
+ --text: #c8d8f0;
18
+ --muted: #4a6080;
19
+ --mono: 'Share Tech Mono', monospace;
20
+ --sans: 'Syne', sans-serif;
21
+ }
22
+
23
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
24
+
25
+ body {
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: var(--sans);
29
+ min-height: 100vh;
30
+ overflow-x: hidden;
31
+ }
32
+
33
+ /* ── Grid bg ── */
34
+ body::before {
35
+ content: '';
36
+ position: fixed;
37
+ inset: 0;
38
+ background-image:
39
+ linear-gradient(rgba(0,212,255,.03) 1px, transparent 1px),
40
+ linear-gradient(90deg, rgba(0,212,255,.03) 1px, transparent 1px);
41
+ background-size: 40px 40px;
42
+ pointer-events: none;
43
+ z-index: 0;
44
+ }
45
+
46
+ /* ── Glow blobs ── */
47
+ .blob {
48
+ position: fixed;
49
+ border-radius: 50%;
50
+ filter: blur(120px);
51
+ pointer-events: none;
52
+ z-index: 0;
53
+ opacity: .35;
54
+ }
55
+ .blob-1 { width: 500px; height: 500px; top: -150px; left: -100px; background: var(--accent2); }
56
+ .blob-2 { width: 400px; height: 400px; bottom: -100px; right: -80px; background: var(--accent); }
57
+
58
+ /* ── Layout ── */
59
+ .wrapper { position: relative; z-index: 1; max-width: 1100px; margin: 0 auto; padding: 40px 24px 80px; }
60
+
61
+ /* ── Header ── */
62
+ header { display: flex; align-items: center; gap: 16px; margin-bottom: 48px; }
63
+ .logo-icon {
64
+ width: 48px; height: 48px;
65
+ background: linear-gradient(135deg, var(--accent2), var(--accent));
66
+ border-radius: 12px;
67
+ display: grid; place-items: center;
68
+ font-size: 22px;
69
+ box-shadow: 0 0 30px rgba(0,212,255,.3);
70
+ flex-shrink: 0;
71
+ }
72
+ header h1 { font-size: 28px; font-weight: 800; letter-spacing: -1px; }
73
+ header h1 span { color: var(--accent); }
74
+ header p { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-top: 3px; }
75
+ .status-dot {
76
+ width: 8px; height: 8px; border-radius: 50%;
77
+ background: var(--success);
78
+ box-shadow: 0 0 8px var(--success);
79
+ animation: pulse 2s infinite;
80
+ margin-left: auto; flex-shrink: 0;
81
+ }
82
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
83
+
84
+ /* ── Config bar ── */
85
+ .config-bar {
86
+ background: var(--surface);
87
+ border: 1px solid var(--border);
88
+ border-radius: 14px;
89
+ padding: 20px 24px;
90
+ display: flex; gap: 16px; flex-wrap: wrap;
91
+ align-items: flex-end;
92
+ margin-bottom: 36px;
93
+ }
94
+ .config-bar label { font-family: var(--mono); font-size: 11px; color: var(--muted); display: block; margin-bottom: 6px; letter-spacing: .08em; }
95
+ .config-bar input {
96
+ background: var(--bg);
97
+ border: 1px solid var(--border);
98
+ border-radius: 8px;
99
+ color: var(--text);
100
+ font-family: var(--mono);
101
+ font-size: 13px;
102
+ padding: 10px 14px;
103
+ outline: none;
104
+ transition: border-color .2s;
105
+ width: 100%;
106
+ }
107
+ .config-bar input:focus { border-color: var(--accent); }
108
+ .config-bar .field { flex: 1; min-width: 180px; }
109
+ .config-bar .field-key { flex: 1.2; min-width: 220px; }
110
+
111
+ /* ── Tabs ── */
112
+ .tabs { display: flex; gap: 4px; margin-bottom: 28px; }
113
+ .tab {
114
+ padding: 10px 22px;
115
+ border-radius: 8px;
116
+ font-family: var(--mono);
117
+ font-size: 13px;
118
+ cursor: pointer;
119
+ border: 1px solid transparent;
120
+ color: var(--muted);
121
+ transition: all .2s;
122
+ background: none;
123
+ }
124
+ .tab:hover { color: var(--text); border-color: var(--border); }
125
+ .tab.active {
126
+ background: var(--surface);
127
+ border-color: var(--accent);
128
+ color: var(--accent);
129
+ box-shadow: 0 0 20px rgba(0,212,255,.1);
130
+ }
131
+
132
+ /* ── Panel ── */
133
+ .panel { display: none; }
134
+ .panel.active { display: block; }
135
+
136
+ /* ── Upload zone ── */
137
+ .upload-zone {
138
+ border: 2px dashed var(--border);
139
+ border-radius: 16px;
140
+ padding: 60px 40px;
141
+ text-align: center;
142
+ cursor: pointer;
143
+ transition: all .25s;
144
+ position: relative;
145
+ overflow: hidden;
146
+ background: var(--surface);
147
+ }
148
+ .upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(0,212,255,.04); }
149
+ .upload-zone .icon { font-size: 48px; margin-bottom: 16px; display: block; }
150
+ .upload-zone h3 { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
151
+ .upload-zone p { font-family: var(--mono); font-size: 12px; color: var(--muted); }
152
+ .upload-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
153
+
154
+ /* ── Progress ── */
155
+ .progress-wrap { margin-top: 24px; display: none; }
156
+ .progress-wrap.show { display: block; }
157
+ .progress-bar-bg {
158
+ background: var(--border);
159
+ border-radius: 100px;
160
+ height: 6px;
161
+ overflow: hidden;
162
+ }
163
+ .progress-bar-fill {
164
+ height: 100%;
165
+ border-radius: 100px;
166
+ background: linear-gradient(90deg, var(--accent2), var(--accent));
167
+ width: 0%;
168
+ transition: width .3s ease;
169
+ box-shadow: 0 0 12px var(--accent);
170
+ }
171
+ .progress-label { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-top: 10px; }
172
+
173
+ /* ── Response box ── */
174
+ .response-box {
175
+ background: #020509;
176
+ border: 1px solid var(--border);
177
+ border-radius: 12px;
178
+ padding: 20px;
179
+ font-family: var(--mono);
180
+ font-size: 12.5px;
181
+ line-height: 1.7;
182
+ color: var(--success);
183
+ white-space: pre-wrap;
184
+ word-break: break-all;
185
+ margin-top: 20px;
186
+ min-height: 80px;
187
+ display: none;
188
+ }
189
+ .response-box.show { display: block; }
190
+ .response-box.error { color: var(--danger); }
191
+
192
+ /* ── File list ── */
193
+ .list-controls { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; }
194
+ .btn {
195
+ padding: 10px 20px;
196
+ border-radius: 8px;
197
+ font-family: var(--mono);
198
+ font-size: 13px;
199
+ cursor: pointer;
200
+ border: 1px solid var(--accent);
201
+ background: transparent;
202
+ color: var(--accent);
203
+ transition: all .2s;
204
+ display: inline-flex; align-items: center; gap: 8px;
205
+ }
206
+ .btn:hover { background: var(--accent); color: var(--bg); box-shadow: 0 0 20px rgba(0,212,255,.3); }
207
+ .btn.danger { border-color: var(--danger); color: var(--danger); }
208
+ .btn.danger:hover { background: var(--danger); color: #fff; box-shadow: 0 0 20px rgba(255,60,110,.3); }
209
+ .btn.success { border-color: var(--success); color: var(--success); }
210
+ .btn.success:hover { background: var(--success); color: var(--bg); }
211
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
212
+
213
+ .files-grid { display: flex; flex-direction: column; gap: 10px; }
214
+
215
+ .file-card {
216
+ background: var(--surface);
217
+ border: 1px solid var(--border);
218
+ border-radius: 12px;
219
+ padding: 16px 20px;
220
+ display: flex; align-items: center; gap: 16px;
221
+ transition: border-color .2s, transform .15s;
222
+ animation: fadeUp .3s ease both;
223
+ }
224
+ .file-card:hover { border-color: var(--accent); transform: translateY(-1px); }
225
+ @keyframes fadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
226
+
227
+ .file-icon {
228
+ width: 42px; height: 42px;
229
+ border-radius: 10px;
230
+ display: grid; place-items: center;
231
+ font-size: 20px;
232
+ flex-shrink: 0;
233
+ background: rgba(0,212,255,.08);
234
+ border: 1px solid rgba(0,212,255,.15);
235
+ }
236
+ .file-info { flex: 1; min-width: 0; }
237
+ .file-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; }
238
+ .file-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); display: flex; gap: 16px; flex-wrap: wrap; }
239
+ .file-actions { display: flex; gap: 8px; flex-shrink: 0; }
240
+ .icon-btn {
241
+ width: 36px; height: 36px;
242
+ border-radius: 8px;
243
+ border: 1px solid var(--border);
244
+ background: transparent;
245
+ color: var(--muted);
246
+ cursor: pointer;
247
+ display: grid; place-items: center;
248
+ font-size: 16px;
249
+ transition: all .2s;
250
+ }
251
+ .icon-btn:hover.dl { border-color: var(--success); color: var(--success); background: rgba(0,255,179,.08); }
252
+ .icon-btn:hover.del { border-color: var(--danger); color: var(--danger); background: rgba(255,60,110,.08); }
253
+
254
+ /* ── Download panel ── */
255
+ .get-form { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 28px; }
256
+ .get-form label { font-family: var(--mono); font-size: 11px; color: var(--muted); display: block; margin-bottom: 8px; letter-spacing: .08em; }
257
+ .get-form input {
258
+ background: var(--bg);
259
+ border: 1px solid var(--border);
260
+ border-radius: 8px;
261
+ color: var(--text);
262
+ font-family: var(--mono);
263
+ font-size: 13px;
264
+ padding: 12px 16px;
265
+ width: 100%;
266
+ outline: none;
267
+ margin-bottom: 16px;
268
+ transition: border-color .2s;
269
+ }
270
+ .get-form input:focus { border-color: var(--accent); }
271
+
272
+ /* ── Empty state ── */
273
+ .empty {
274
+ text-align: center;
275
+ padding: 60px 20px;
276
+ color: var(--muted);
277
+ font-family: var(--mono);
278
+ font-size: 13px;
279
+ }
280
+ .empty span { font-size: 40px; display: block; margin-bottom: 16px; }
281
+
282
+ /* ── Toast ── */
283
+ #toast {
284
+ position: fixed;
285
+ bottom: 32px; right: 32px;
286
+ background: var(--surface);
287
+ border: 1px solid var(--accent);
288
+ border-radius: 10px;
289
+ padding: 14px 22px;
290
+ font-family: var(--mono);
291
+ font-size: 13px;
292
+ color: var(--accent);
293
+ box-shadow: 0 8px 40px rgba(0,0,0,.6);
294
+ transform: translateY(80px);
295
+ opacity: 0;
296
+ transition: all .3s ease;
297
+ z-index: 999;
298
+ max-width: 340px;
299
+ }
300
+ #toast.show { transform: translateY(0); opacity: 1; }
301
+ #toast.err { border-color: var(--danger); color: var(--danger); }
302
+
303
+ /* ── Spinner ── */
304
+ .spin {
305
+ width: 16px; height: 16px;
306
+ border: 2px solid var(--border);
307
+ border-top-color: var(--accent);
308
+ border-radius: 50%;
309
+ animation: spin .6s linear infinite;
310
+ display: inline-block;
311
+ }
312
+ @keyframes spin { to{transform:rotate(360deg)} }
313
+
314
+
315
+ /* ── CDN URL box ── */
316
+ .cdn-box {
317
+ display: none;
318
+ margin-top: 16px;
319
+ background: rgba(0,255,179,.05);
320
+ border: 1px solid rgba(0,255,179,.3);
321
+ border-radius: 12px;
322
+ padding: 14px 18px;
323
+ animation: fadeUp .3s ease;
324
+ }
325
+ .cdn-box.show { display: block; }
326
+ .cdn-box label {
327
+ font-family: var(--mono);
328
+ font-size: 10px;
329
+ color: var(--success);
330
+ letter-spacing: .1em;
331
+ display: block;
332
+ margin-bottom: 8px;
333
+ }
334
+ .cdn-url-row {
335
+ display: flex;
336
+ gap: 8px;
337
+ align-items: center;
338
+ }
339
+ .cdn-url-input {
340
+ flex: 1;
341
+ background: var(--bg);
342
+ border: 1px solid rgba(0,255,179,.2);
343
+ border-radius: 8px;
344
+ color: var(--success);
345
+ font-family: var(--mono);
346
+ font-size: 12px;
347
+ padding: 9px 13px;
348
+ outline: none;
349
+ min-width: 0;
350
+ }
351
+ .cdn-copy-btn {
352
+ padding: 9px 16px;
353
+ border-radius: 8px;
354
+ border: 1px solid var(--success);
355
+ background: transparent;
356
+ color: var(--success);
357
+ font-family: var(--mono);
358
+ font-size: 12px;
359
+ cursor: pointer;
360
+ white-space: nowrap;
361
+ transition: all .2s;
362
+ flex-shrink: 0;
363
+ }
364
+ .cdn-copy-btn:hover { background: var(--success); color: var(--bg); }
365
+ .cdn-open-btn {
366
+ padding: 9px 14px;
367
+ border-radius: 8px;
368
+ border: 1px solid rgba(0,255,179,.3);
369
+ background: transparent;
370
+ color: var(--muted);
371
+ font-family: var(--mono);
372
+ font-size: 12px;
373
+ cursor: pointer;
374
+ text-decoration: none;
375
+ white-space: nowrap;
376
+ transition: all .2s;
377
+ flex-shrink: 0;
378
+ }
379
+ .cdn-open-btn:hover { border-color: var(--success); color: var(--success); }
380
+
381
+
382
+ /* ── Custom path input ── */
383
+ .custom-path-wrap {
384
+ margin-top: 16px;
385
+ background: var(--surface);
386
+ border: 1px solid var(--border);
387
+ border-radius: 12px;
388
+ padding: 16px 18px;
389
+ }
390
+ .custom-path-wrap label {
391
+ font-family: var(--mono);
392
+ font-size: 10px;
393
+ color: var(--muted);
394
+ letter-spacing: .1em;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ margin-bottom: 8px;
399
+ }
400
+ .custom-path-wrap label span.opt {
401
+ background: rgba(0,212,255,.1);
402
+ color: var(--accent);
403
+ border-radius: 4px;
404
+ padding: 1px 6px;
405
+ font-size: 9px;
406
+ }
407
+ .path-row {
408
+ display: flex;
409
+ align-items: center;
410
+ gap: 0;
411
+ background: var(--bg);
412
+ border: 1px solid var(--border);
413
+ border-radius: 8px;
414
+ overflow: hidden;
415
+ transition: border-color .2s;
416
+ }
417
+ .path-row:focus-within { border-color: var(--accent); }
418
+ .path-prefix {
419
+ font-family: var(--mono);
420
+ font-size: 12px;
421
+ color: var(--muted);
422
+ padding: 10px 0 10px 13px;
423
+ white-space: nowrap;
424
+ flex-shrink: 0;
425
+ user-select: none;
426
+ }
427
+ .path-input {
428
+ flex: 1;
429
+ background: transparent;
430
+ border: none;
431
+ color: var(--accent);
432
+ font-family: var(--mono);
433
+ font-size: 12px;
434
+ padding: 10px 13px 10px 4px;
435
+ outline: none;
436
+ min-width: 0;
437
+ }
438
+ .path-input::placeholder { color: var(--muted); }
439
+ .path-hint {
440
+ font-family: var(--mono);
441
+ font-size: 11px;
442
+ color: var(--muted);
443
+ margin-top: 7px;
444
+ }
445
+ .path-hint span { color: var(--accent); opacity: .7; }
446
+
447
+ /* ── Scrollbar ── */
448
+ ::-webkit-scrollbar { width: 6px; }
449
+ ::-webkit-scrollbar-track { background: var(--bg); }
450
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
451
+ </style>
452
+ </head>
453
+ <body>
454
+
455
+ <div class="blob blob-1"></div>
456
+ <div class="blob blob-2"></div>
457
+
458
+ <div class="wrapper">
459
+
460
+ <!-- Header -->
461
+ <header>
462
+ <div class="logo-icon">πŸ“‘</div>
463
+ <div>
464
+ <h1>TG <span>Storage</span></h1>
465
+ <p>telegram-powered file vault // api tester</p>
466
+ </div>
467
+ <div class="status-dot" id="statusDot" title="checking..."></div>
468
+ </header>
469
+
470
+ <!-- Config -->
471
+ <div class="config-bar">
472
+ <div class="field">
473
+ <label>API BASE URL</label>
474
+ <input id="baseUrl" type="text" value="http://localhost:8082" placeholder="http://localhost:8082" />
475
+ </div>
476
+ <div class="field-key">
477
+ <label>X-API-KEY</label>
478
+ <input id="apiKey" type="password" placeholder="your-admin-api-key" />
479
+ </div>
480
+ <button class="btn" onclick="checkHealth()">⚑ ping</button>
481
+ </div>
482
+
483
+ <!-- Tabs -->
484
+ <div class="tabs">
485
+ <button class="tab active" data-tab="upload">↑ Upload</button>
486
+ <button class="tab" data-tab="files">≑ Files</button>
487
+ <button class="tab" data-tab="download">↓ Download</button>
488
+ </div>
489
+
490
+ <!-- ── Upload Panel ── -->
491
+ <div class="panel active" id="tab-upload">
492
+ <div class="upload-zone" id="dropZone">
493
+ <span class="icon">πŸ“‚</span>
494
+ <h3>Drop a file here</h3>
495
+ <p>or click to browse β€” any format, any size</p>
496
+ <input type="file" id="fileInput" onchange="handleFileSelect()" />
497
+ </div>
498
+
499
+ <div class="custom-path-wrap">
500
+ <label>CUSTOM CDN PATH <span class="opt">optional</span></label>
501
+ <div class="path-row">
502
+ <span class="path-prefix" id="pathPrefix">/cdn/</span>
503
+ <input class="path-input" id="customPath" type="text"
504
+ placeholder="images/logo.png or avatar.jpg or docs/readme.pdf"
505
+ oninput="updatePathPreview()" />
506
+ </div>
507
+ <div class="path-hint" id="pathHint">Leave blank to use auto-generated ID &nbsp;Β·&nbsp; Use <span>/</span> for folders</div>
508
+ </div>
509
+
510
+ <div class="progress-wrap" id="progressWrap">
511
+ <div class="progress-bar-bg"><div class="progress-bar-fill" id="progressFill"></div></div>
512
+ <div class="progress-label" id="progressLabel">Uploading…</div>
513
+ </div>
514
+
515
+ <div class="cdn-box" id="cdnBox">
516
+ <label>πŸ”— PUBLIC CDN URL β€” shareable, no auth required</label>
517
+ <div class="cdn-url-row">
518
+ <input class="cdn-url-input" id="cdnUrlInput" type="text" readonly />
519
+ <button class="cdn-copy-btn" onclick="copyCdnUrl()">⎘ Copy</button>
520
+ <a class="cdn-open-btn" id="cdnOpenLink" href="#" target="_blank" rel="noopener">β†— Open</a>
521
+ </div>
522
+ </div>
523
+ <pre class="response-box" id="uploadResponse"></pre>
524
+ </div>
525
+
526
+ <!-- ── Files Panel ── -->
527
+ <div class="panel" id="tab-files">
528
+ <div class="list-controls">
529
+ <button class="btn" onclick="loadFiles()">↻ Refresh</button>
530
+ <span id="fileCount" style="font-family:var(--mono);font-size:12px;color:var(--muted)"></span>
531
+ </div>
532
+ <div class="files-grid" id="filesGrid">
533
+ <div class="empty"><span>πŸ“­</span>Hit refresh to load files</div>
534
+ </div>
535
+ </div>
536
+
537
+ <!-- ── Download Panel ── -->
538
+ <div class="panel" id="tab-download">
539
+ <div class="get-form">
540
+ <label>FILE ID</label>
541
+ <input id="dlFileId" type="text" placeholder="550e8400-e29b-41d4-a716-446655440000" />
542
+ <button class="btn success" onclick="downloadFile()">↓ Download File</button>
543
+ </div>
544
+ <pre class="response-box" id="dlResponse"></pre>
545
+ </div>
546
+
547
+ </div><!-- /wrapper -->
548
+
549
+ <div id="toast"></div>
550
+
551
+ <script>
552
+ // ──────────────────────────────────────────────
553
+ // Helpers
554
+ // ──────────────────────────────────────────────
555
+ const $ = id => document.getElementById(id);
556
+
557
+ function cfg() {
558
+ return {
559
+ base: ($('baseUrl').value || 'http://localhost:8082').replace(/\/$/, ''),
560
+ key: $('apiKey').value,
561
+ };
562
+ }
563
+
564
+ function headers(extra = {}) {
565
+ return { 'X-API-Key': cfg().key, ...extra };
566
+ }
567
+
568
+ function toast(msg, err = false) {
569
+ const t = $('toast');
570
+ t.textContent = msg;
571
+ t.className = 'show' + (err ? ' err' : '');
572
+ clearTimeout(t._t);
573
+ t._t = setTimeout(() => t.className = '', 3200);
574
+ }
575
+
576
+ function showResponse(elId, data, isError = false) {
577
+ const el = $(elId);
578
+ el.textContent = JSON.stringify(data, null, 2);
579
+ el.className = 'response-box show' + (isError ? ' error' : '');
580
+ }
581
+
582
+ function fileIcon(mime = '') {
583
+ if (mime.startsWith('image/')) return 'πŸ–ΌοΈ';
584
+ if (mime.startsWith('video/')) return '🎬';
585
+ if (mime.startsWith('audio/')) return '🎡';
586
+ if (mime.includes('pdf')) return 'πŸ“„';
587
+ if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return 'πŸ—œοΈ';
588
+ if (mime.includes('text')) return 'πŸ“';
589
+ return 'πŸ“¦';
590
+ }
591
+
592
+ function formatBytes(b) {
593
+ if (b < 1024) return b + ' B';
594
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
595
+ return (b / 1048576).toFixed(1) + ' MB';
596
+ }
597
+
598
+ // ──────────────────────────────────────────────
599
+ // Tabs
600
+ // ──────────────────────────────────────────────
601
+ document.querySelectorAll('.tab').forEach(btn => {
602
+ btn.addEventListener('click', () => {
603
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
604
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
605
+ btn.classList.add('active');
606
+ $('tab-' + btn.dataset.tab).classList.add('active');
607
+ if (btn.dataset.tab === 'files') loadFiles();
608
+ });
609
+ });
610
+
611
+ // ──────────────────────────────────────────────
612
+ // Health check
613
+ // ──────────────────────────────────────────────
614
+ async function checkHealth() {
615
+ const dot = $('statusDot');
616
+ dot.style.background = '#ffd700';
617
+ dot.style.boxShadow = '0 0 8px #ffd700';
618
+ try {
619
+ const r = await fetch(cfg().base + '/health', { headers: headers() });
620
+ const d = await r.json();
621
+ dot.style.background = 'var(--success)';
622
+ dot.style.boxShadow = '0 0 8px var(--success)';
623
+ toast('βœ“ API online β€” ' + d.timestamp);
624
+ } catch (e) {
625
+ dot.style.background = 'var(--danger)';
626
+ dot.style.boxShadow = '0 0 8px var(--danger)';
627
+ toast('βœ— Cannot reach API', true);
628
+ }
629
+ }
630
+ checkHealth();
631
+
632
+ // ──────────────────────────────────────────────
633
+ // Drag & Drop
634
+ // ──────────────────────────────────────────────
635
+ const dz = $('dropZone');
636
+ ['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('drag'); }));
637
+ ['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('drag'); }));
638
+ dz.addEventListener('drop', e => {
639
+ const file = e.dataTransfer.files[0];
640
+ if (file) uploadFile(file);
641
+ });
642
+
643
+ function handleFileSelect() {
644
+ const file = $('fileInput').files[0];
645
+ if (file) uploadFile(file);
646
+ }
647
+
648
+ // ──────────────────────────────────────────────
649
+ // Upload
650
+ // ──────────────────────────────────────────────
651
+ async function uploadFile(file) {
652
+ const pw = $('progressWrap');
653
+ const pf = $('progressFill');
654
+ const pl = $('progressLabel');
655
+ const rb = $('uploadResponse');
656
+
657
+ pw.className = 'progress-wrap show';
658
+ rb.className = 'response-box';
659
+ $('cdnBox').className = 'cdn-box';
660
+ $('cdnBox').querySelector('label').textContent = 'πŸ”— PUBLIC CDN URL β€” shareable, no auth required';
661
+ pf.style.width = '0%';
662
+ pl.textContent = `Uploading ${file.name} (${formatBytes(file.size)})…`;
663
+
664
+ // Fake progress animation while real request runs
665
+ let prog = 0;
666
+ const tick = setInterval(() => {
667
+ prog = Math.min(prog + Math.random() * 12, 88);
668
+ pf.style.width = prog + '%';
669
+ }, 200);
670
+
671
+ try {
672
+ const fd = new FormData();
673
+ fd.append('file', file);
674
+ const cp = $('customPath').value.trim();
675
+ if (cp) fd.append('custom_path', cp);
676
+ const r = await fetch(cfg().base + '/upload', {
677
+ method: 'POST',
678
+ headers: { 'X-API-Key': cfg().key },
679
+ body: fd,
680
+ });
681
+ const data = await r.json();
682
+ clearInterval(tick);
683
+ pf.style.width = '100%';
684
+ pl.textContent = r.ok ? `βœ“ Uploaded successfully` : `βœ— Upload failed`;
685
+
686
+ showResponse('uploadResponse', data, !r.ok);
687
+ if (r.ok) {
688
+ toast(`βœ“ ${file.name} uploaded!`);
689
+ if (data.public_url) {
690
+ $('cdnUrlInput').value = data.public_url;
691
+ $('cdnOpenLink').href = data.public_url;
692
+ // Update label to indicate whether custom path or UUID is used
693
+ const lbl = $('cdnBox').querySelector('label');
694
+ if (data.custom_path) {
695
+ lbl.textContent = `πŸ”— CDN URL β€’ custom path: /${data.custom_path}`;
696
+ } else {
697
+ lbl.textContent = 'πŸ”— PUBLIC CDN URL β€” shareable, no auth required';
698
+ }
699
+ $('cdnBox').className = 'cdn-box show';
700
+ }
701
+ navigator.clipboard?.writeText(data.public_url || data.file_id).catch(() => {});
702
+ // Reset custom path input for next upload
703
+ $('customPath').value = '';
704
+ updatePathPreview();
705
+ } else {
706
+ $('cdnBox').className = 'cdn-box';
707
+ toast('βœ— Upload failed', true);
708
+ }
709
+ } catch (e) {
710
+ clearInterval(tick);
711
+ pf.style.width = '0%';
712
+ pl.textContent = 'βœ— Request failed';
713
+ showResponse('uploadResponse', { error: e.message }, true);
714
+ toast('βœ— Network error', true);
715
+ }
716
+ }
717
+
718
+ // ──────────────────────────────────────────────
719
+ // List files
720
+ // ──────────────────────────────────────────────
721
+ async function loadFiles() {
722
+ const grid = $('filesGrid');
723
+ const countEl = $('fileCount');
724
+ grid.innerHTML = '<div class="empty"><span><div class="spin"></div></span>Loading…</div>';
725
+ try {
726
+ const r = await fetch(cfg().base + '/files?limit=100', { headers: headers() });
727
+ const data = await r.json();
728
+ if (!r.ok) { showFiles([], data); return; }
729
+ countEl.textContent = `${data.total} file${data.total !== 1 ? 's' : ''}`;
730
+ showFiles(data.files);
731
+ } catch (e) {
732
+ grid.innerHTML = `<div class="empty"><span>⚠️</span>${e.message}</div>`;
733
+ }
734
+ }
735
+
736
+ function showFiles(files) {
737
+ const grid = $('filesGrid');
738
+ if (!files.length) {
739
+ grid.innerHTML = '<div class="empty"><span>πŸ“­</span>No files stored yet</div>';
740
+ return;
741
+ }
742
+ grid.innerHTML = files.map((f, i) => `
743
+ <div class="file-card" style="animation-delay:${i * 40}ms">
744
+ <div class="file-icon">${fileIcon(f.mime_type)}</div>
745
+ <div class="file-info">
746
+ <div class="file-name" title="${f.filename}">${f.filename}</div>
747
+ <div class="file-meta">
748
+ <span>${formatBytes(f.size_bytes)}</span>
749
+ <span>${f.mime_type}</span>
750
+ <span>${f.uploaded_at?.slice(0, 16).replace('T', ' ')}</span>
751
+ <span style="color:var(--accent);cursor:pointer" onclick="copyId('${f.file_id}')" title="Click to copy file ID">${f.file_id.slice(0, 18)}…</span>
752
+ ${f.custom_path ? `<span style="color:var(--accent);opacity:.6" title="custom path">/${f.custom_path}</span>` : ''}
753
+ ${f.public_url ? `<span style="color:var(--success);cursor:pointer;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" onclick="copyCdnUrlStr('${f.public_url}')" title="${f.public_url}">πŸ”— CDN link</span>` : ''}
754
+ </div>
755
+ </div>
756
+ <div class="file-actions">
757
+ ${f.public_url ? `<button class="icon-btn dl" title="Copy CDN URL" onclick="copyCdnUrlStr('${f.public_url}')">πŸ”—</button>` : ''}
758
+ <button class="icon-btn dl" title="Download" onclick="triggerDownload('${f.file_id}','${f.filename}')">↓</button>
759
+ <button class="icon-btn del" title="Delete" onclick="deleteFile('${f.file_id}', this)">βœ•</button>
760
+ </div>
761
+ </div>
762
+ `).join('');
763
+ }
764
+
765
+ function updatePathPreview() {
766
+ const val = $('customPath').value.trim();
767
+ const hint = $('pathHint');
768
+ if (val) {
769
+ const clean = val.replace(/^\/+/, '');
770
+ hint.innerHTML = `CDN URL will be: <span>${cfg().base}/cdn/${clean}</span>`;
771
+ } else {
772
+ hint.innerHTML = `Leave blank to use auto-generated ID &nbsp;Β·&nbsp; Use <span>/</span> for folders`;
773
+ }
774
+ }
775
+
776
+ function copyId(id) {
777
+ navigator.clipboard?.writeText(id);
778
+ toast('βœ“ File ID copied');
779
+ }
780
+
781
+ function copyCdnUrl() {
782
+ const url = $('cdnUrlInput').value;
783
+ navigator.clipboard?.writeText(url);
784
+ const btn = document.querySelector('.cdn-copy-btn');
785
+ btn.textContent = 'βœ“ Copied!';
786
+ setTimeout(() => btn.textContent = '⎘ Copy', 2000);
787
+ toast('βœ“ CDN URL copied to clipboard');
788
+ }
789
+
790
+ function copyCdnUrlStr(url) {
791
+ navigator.clipboard?.writeText(url);
792
+ toast('βœ“ CDN URL copied');
793
+ }
794
+
795
+ // ──────────────────────────────────────────────
796
+ // Download
797
+ // ──────────────────────────────────────────────
798
+ async function triggerDownload(fileId, filename) {
799
+ try {
800
+ const r = await fetch(cfg().base + '/file/' + fileId, { headers: headers() });
801
+ if (!r.ok) { toast('βœ— Download failed', true); return; }
802
+ const blob = await r.blob();
803
+ const url = URL.createObjectURL(blob);
804
+ const a = document.createElement('a');
805
+ a.href = url; a.download = filename;
806
+ a.click();
807
+ URL.revokeObjectURL(url);
808
+ toast(`↓ ${filename} downloaded`);
809
+ } catch (e) {
810
+ toast('βœ— ' + e.message, true);
811
+ }
812
+ }
813
+
814
+ async function downloadFile() {
815
+ const id = $('dlFileId').value.trim();
816
+ if (!id) { toast('βœ— Enter a file ID', true); return; }
817
+ const rb = $('dlResponse');
818
+ rb.className = 'response-box show';
819
+ rb.textContent = 'Fetching…';
820
+ try {
821
+ const r = await fetch(cfg().base + '/file/' + id, { headers: headers() });
822
+ if (!r.ok) {
823
+ const d = await r.json();
824
+ showResponse('dlResponse', d, true);
825
+ toast('βœ— Not found', true);
826
+ return;
827
+ }
828
+ const blob = await r.blob();
829
+ const cd = r.headers.get('Content-Disposition') || '';
830
+ const fnMatch = cd.match(/filename="?([^"]+)"?/);
831
+ const filename = fnMatch ? fnMatch[1] : 'download';
832
+ const url = URL.createObjectURL(blob);
833
+ const a = document.createElement('a');
834
+ a.href = url; a.download = filename;
835
+ a.click();
836
+ URL.revokeObjectURL(url);
837
+ rb.textContent = `βœ“ Downloaded: ${filename} (${formatBytes(blob.size)})`;
838
+ rb.className = 'response-box show';
839
+ toast(`↓ ${filename} saved`);
840
+ } catch (e) {
841
+ showResponse('dlResponse', { error: e.message }, true);
842
+ toast('βœ— Network error', true);
843
+ }
844
+ }
845
+
846
+ // ──────────────────────────────────────────────
847
+ // Delete
848
+ // ────────────────��─────────────────────────────
849
+ async function deleteFile(fileId, btn) {
850
+ if (!confirm('Delete this file record?')) return;
851
+ btn.textContent = '…';
852
+ btn.disabled = true;
853
+ try {
854
+ const r = await fetch(cfg().base + '/file/' + fileId, {
855
+ method: 'DELETE', headers: headers()
856
+ });
857
+ const d = await r.json();
858
+ if (r.ok) {
859
+ toast('βœ“ File record deleted');
860
+ loadFiles();
861
+ } else {
862
+ toast('βœ— Delete failed', true);
863
+ btn.textContent = 'βœ•';
864
+ btn.disabled = false;
865
+ }
866
+ } catch (e) {
867
+ toast('βœ— ' + e.message, true);
868
+ btn.textContent = 'βœ•';
869
+ btn.disabled = false;
870
+ }
871
+ }
872
+ </script>
873
+ </body>
874
+ </html>
main.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TG Storage API β€” Store & retrieve files via Telegram as a backend.
3
+
4
+ Endpoints:
5
+ GET / β€” Frontend UI
6
+ POST /upload β€” Upload a file (optional custom_path)
7
+ GET /cdn/{path} β€” Public CDN URL β€” works with:
8
+ /cdn/<file_id>
9
+ /cdn/<custom_path> e.g. /cdn/logo.png
10
+ /cdn/<folder/name.ext> e.g. /cdn/images/avatar.jpg
11
+ GET /file/{file_id} β€” Download (auth required, forces attachment)
12
+ GET /files β€” List all stored files
13
+ DELETE /file/{file_id} β€” Delete a file record
14
+ GET /health β€” Health check
15
+ """
16
+
17
+ import os
18
+ import io
19
+ import re
20
+ import uuid
21
+ import logging
22
+ import mimetypes
23
+ from contextlib import asynccontextmanager
24
+ from datetime import datetime
25
+ from typing import Optional
26
+ from pathlib import Path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ from fastapi import FastAPI, UploadFile, File, Form, Header, HTTPException, Depends, Query, Request
31
+ from fastapi.responses import StreamingResponse, HTMLResponse
32
+ from fastapi.middleware.cors import CORSMiddleware
33
+
34
+ from db import (
35
+ init_db, save_file_record,
36
+ get_file_record, get_file_by_custom_path,
37
+ list_file_records, delete_file_record, count_files,
38
+ )
39
+ from tg import upload_to_telegram, download_from_telegram, init_bot_pool, close_http
40
+
41
+ # ──────────────────────────────────────────────────
42
+ # Lifespan
43
+ # ──────────────────────────────────────────────────
44
+ @asynccontextmanager
45
+ async def lifespan(app: FastAPI):
46
+ await init_db() # connect MongoDB Atlas + ensure indexes
47
+ await init_bot_pool() # verify tokens.txt & build bot pool
48
+ yield
49
+ await close_http() # drain httpx connection pool
50
+
51
+ # ──────────────────────────────────────────────────
52
+ # App
53
+ # ──────────────────────────────────────────────────
54
+ app = FastAPI(
55
+ title="TG Storage API",
56
+ description="Infinite file storage powered by Telegram",
57
+ version="4.0.0",
58
+ lifespan=lifespan,
59
+ )
60
+
61
+ app.add_middleware(
62
+ CORSMiddleware,
63
+ allow_origins=["*"],
64
+ allow_methods=["*"],
65
+ allow_headers=["*"],
66
+ )
67
+
68
+ ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "changeme")
69
+ BASE_URL = os.getenv("BASE_URL", "http://localhost:8082").rstrip("/")
70
+
71
+ _HERE = Path(__file__).parent
72
+ FRONTEND_PATH = _HERE / "frontend.html"
73
+
74
+ # Allowed characters in a custom path segment:
75
+ # alphanumeric, hyphen, underscore, dot, forward slash
76
+ _CUSTOM_PATH_RE = re.compile(r'^[a-zA-Z0-9._\-/]+$')
77
+
78
+
79
+ # ──────────────────────────────────────────────────
80
+ # Helpers
81
+ # ──────────────────────────────────────────────────
82
+ async def require_api_key(x_api_key: str = Header(..., description="Your API key")):
83
+ if x_api_key != ADMIN_API_KEY:
84
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
85
+ return x_api_key
86
+
87
+
88
+ def _sanitize_custom_path(raw: str) -> str:
89
+ """
90
+ Normalise and validate a custom path.
91
+ - Strip leading/trailing slashes and whitespace
92
+ - Reject empty, path-traversal attempts, or illegal characters
93
+ """
94
+ path = raw.strip().strip("/")
95
+ if not path:
96
+ raise HTTPException(status_code=400, detail="custom_path cannot be empty after stripping slashes.")
97
+ if ".." in path:
98
+ raise HTTPException(status_code=400, detail="custom_path must not contain '..'")
99
+ if not _CUSTOM_PATH_RE.match(path):
100
+ raise HTTPException(
101
+ status_code=400,
102
+ detail="custom_path may only contain letters, digits, hyphens, underscores, dots, and slashes."
103
+ )
104
+ return path
105
+
106
+
107
+ def _build_public_url(identifier: str) -> str:
108
+ """identifier is either a file_id UUID or a normalised custom_path."""
109
+ return f"{BASE_URL}/cdn/{identifier}"
110
+
111
+
112
+ async def _stream_record(record: dict) -> StreamingResponse:
113
+ """Download from Telegram and stream to client."""
114
+ try:
115
+ data: bytes = await download_from_telegram(record["tg_message_id"], record["tg_file_id"])
116
+ except Exception as exc:
117
+ logger.exception("Telegram download error")
118
+ raise HTTPException(status_code=502, detail=str(exc))
119
+
120
+ return StreamingResponse(
121
+ io.BytesIO(data),
122
+ media_type=record["mime_type"],
123
+ headers={
124
+ "Content-Disposition": f'inline; filename="{record["filename"]}"',
125
+ "Content-Length": str(len(data)),
126
+ "Cache-Control": "public, max-age=31536000, immutable",
127
+ },
128
+ )
129
+
130
+
131
+ # ──────────────────────────────────────────────────
132
+ # Routes
133
+ # ──────────────────────────────────────────────────
134
+
135
+ @app.get("/", include_in_schema=False)
136
+ async def frontend():
137
+ if FRONTEND_PATH.exists():
138
+ return HTMLResponse(FRONTEND_PATH.read_text(encoding="utf-8"))
139
+ return HTMLResponse("<h2>frontend.html not found</h2>", status_code=404)
140
+
141
+
142
+ @app.get("/health", tags=["System"])
143
+ async def health():
144
+ total = await count_files()
145
+ return {"status": "ok", "timestamp": datetime.utcnow().isoformat(),
146
+ "total_files": total, "base_url": BASE_URL}
147
+
148
+
149
+ # ── CDN β€” public, no auth ─────────────────────────────────────────────
150
+ @app.get(
151
+ "/cdn/{path:path}",
152
+ tags=["CDN"],
153
+ summary="Public shareable URL β€” supports UUID file_id or any custom path",
154
+ )
155
+ async def cdn_file(path: str):
156
+ """
157
+ Resolve priority:
158
+ 1. Exact match on custom_path (e.g. /cdn/images/logo.png)
159
+ 2. Exact match on file_id UUID (e.g. /cdn/550e8400-...)
160
+ """
161
+ # 1 β€” custom path lookup
162
+ record = await get_file_by_custom_path(path)
163
+
164
+ # 2 β€” fall back to file_id lookup
165
+ if not record:
166
+ record = await get_file_record(path)
167
+
168
+ if not record:
169
+ raise HTTPException(
170
+ status_code=404,
171
+ detail=f"No file found for path '{path}'. "
172
+ f"Provide a valid file_id or a custom_path assigned at upload."
173
+ )
174
+
175
+ return await _stream_record(record)
176
+
177
+
178
+ # ── Upload ────────────────────────────────────────────────────────────
179
+ @app.post(
180
+ "/upload",
181
+ tags=["Files"],
182
+ summary="Upload a file. Optionally assign a custom CDN path.",
183
+ )
184
+ async def upload_file(
185
+ file: UploadFile = File(...),
186
+ custom_path: Optional[str] = Form(
187
+ default=None,
188
+ description=(
189
+ "Optional vanity path for the CDN URL. "
190
+ "Examples: 'logo.png', 'images/avatar.jpg', 'docs/readme.md'. "
191
+ "Must be unique. Leave blank to use the auto-generated file_id."
192
+ ),
193
+ ),
194
+ _: str = Depends(require_api_key),
195
+ ):
196
+ content = await file.read()
197
+ if not content:
198
+ raise HTTPException(status_code=400, detail="Empty file.")
199
+
200
+ filename = file.filename or f"upload_{uuid.uuid4().hex}"
201
+ mime_type = file.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
202
+ size = len(content)
203
+
204
+ # Validate + normalise custom_path if provided
205
+ clean_custom_path: str | None = None
206
+ if custom_path and custom_path.strip():
207
+ clean_custom_path = _sanitize_custom_path(custom_path)
208
+ # Check uniqueness before hitting Telegram
209
+ existing = await get_file_by_custom_path(clean_custom_path)
210
+ if existing:
211
+ raise HTTPException(
212
+ status_code=409,
213
+ detail=f"custom_path '{clean_custom_path}' is already taken by file_id={existing['file_id']}."
214
+ )
215
+
216
+ # Upload bytes to Telegram
217
+ try:
218
+ tg_message_id, tg_file_id = await upload_to_telegram(content, filename, mime_type)
219
+ except Exception as exc:
220
+ logger.exception("Telegram upload error")
221
+ raise HTTPException(status_code=502, detail=str(exc))
222
+
223
+ # Build URLs
224
+ file_id = str(uuid.uuid4())
225
+ cdn_key = clean_custom_path if clean_custom_path else file_id
226
+ public_url = _build_public_url(cdn_key)
227
+
228
+ await save_file_record(
229
+ file_id=file_id,
230
+ filename=filename,
231
+ mime_type=mime_type,
232
+ size=size,
233
+ tg_message_id=tg_message_id,
234
+ tg_file_id=tg_file_id,
235
+ public_url=public_url,
236
+ custom_path=clean_custom_path,
237
+ )
238
+
239
+ logger.info(f"Uploaded {filename!r} β†’ {public_url}")
240
+
241
+ return {
242
+ "file_id": file_id,
243
+ "filename": filename,
244
+ "mime_type": mime_type,
245
+ "size_bytes": size,
246
+ "custom_path": clean_custom_path,
247
+ "public_url": public_url,
248
+ "cdn_url_by_id": _build_public_url(file_id),
249
+ "cdn_url_by_path": _build_public_url(clean_custom_path) if clean_custom_path else None,
250
+ "uploaded_at": datetime.utcnow().isoformat(),
251
+ }
252
+
253
+
254
+ # ── Authenticated download ────────────────────────────────────────────
255
+ @app.get("/file/{file_id}", tags=["Files"], summary="Download (auth required, forces attachment)")
256
+ async def download_file(file_id: str, _: str = Depends(require_api_key)):
257
+ record = await get_file_record(file_id)
258
+ if not record:
259
+ raise HTTPException(status_code=404, detail="File not found.")
260
+
261
+ try:
262
+ data: bytes = await download_from_telegram(record["tg_message_id"], record["tg_file_id"])
263
+ except Exception as exc:
264
+ logger.exception("Download error")
265
+ raise HTTPException(status_code=502, detail=str(exc))
266
+
267
+ return StreamingResponse(
268
+ io.BytesIO(data),
269
+ media_type=record["mime_type"],
270
+ headers={
271
+ "Content-Disposition": f'attachment; filename="{record["filename"]}"',
272
+ "Content-Length": str(len(data)),
273
+ },
274
+ )
275
+
276
+
277
+ # ── List ──────────────────────────────────────────────────────────────
278
+ @app.get("/files", tags=["Files"], summary="List all stored files")
279
+ async def list_files(
280
+ limit: int = Query(50, ge=1, le=500),
281
+ offset: int = Query(0, ge=0),
282
+ _: str = Depends(require_api_key),
283
+ ):
284
+ records = await list_file_records(limit=limit, offset=offset)
285
+ total = await count_files()
286
+ return {"total": total, "limit": limit, "offset": offset, "files": records}
287
+
288
+
289
+ # ── Delete ────────────────────────────────────────────────────────────
290
+ @app.delete("/file/{file_id}", tags=["Files"], summary="Delete a file record")
291
+ async def delete_file(file_id: str, _: str = Depends(require_api_key)):
292
+ record = await get_file_record(file_id)
293
+ if not record:
294
+ raise HTTPException(status_code=404, detail="File not found.")
295
+ await delete_file_record(file_id)
296
+ return {"deleted": True, "file_id": file_id}
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi>=0.111.0
2
+ uvicorn[standard]>=0.29.0
3
+ python-multipart>=0.0.9
4
+ httpx>=0.27.0
5
+ motor>=3.4.0
6
+ pymongo>=4.7.0
7
+ python-dotenv>=1.0.1
server.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server.py β€” Entry point. Loads .env then starts Uvicorn.
3
+
4
+ Run:
5
+ python server.py
6
+ or directly:
7
+ uvicorn main:app --host 0.0.0.0 --port 8082
8
+ """
9
+
10
+ import sys
11
+ import uvicorn
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv() # load .env before importing app so env vars are available
15
+
16
+ if __name__ == "__main__":
17
+ # reload=True causes a multiprocessing crash on Python 3.13 + Windows.
18
+ # Use reload only on Python < 3.13, otherwise run without it.
19
+ py313_or_above = sys.version_info >= (3, 13)
20
+
21
+ uvicorn.run(
22
+ "main:app",
23
+ host="0.0.0.0",
24
+ port=8082,
25
+ reload=not py313_or_above, # disabled on 3.13+ Windows to avoid BufferFlags crash
26
+ log_level="info",
27
+ )
tg.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tg.py β€” Pure-HTTP Telegram Bot API client. No tgstorage-cluster, no
3
+ python-telegram-bot. Just httpx + the official Bot API.
4
+
5
+ Bot pool:
6
+ β€’ Reads tokens.txt (one token per line) at startup via init_bot_pool().
7
+ β€’ Verifies each token with getMe(). Skips bad/dead tokens.
8
+ β€’ Round-robins uploads across all healthy bots to spread rate-limit load.
9
+
10
+ Upload flow:
11
+ sendDocument β†’ returns message_id + file_id β†’ stored in MongoDB.
12
+
13
+ Download flow (two-stage):
14
+ 1. getFile(file_id) β†’ get a temporary download path from Telegram.
15
+ 2. GET https://api.telegram.org/file/bot{token}/{file_path} β†’ raw bytes.
16
+ File paths expire after ~1 h, so we always call getFile fresh.
17
+ """
18
+
19
+ import os
20
+ import io
21
+ import itertools
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Tuple
25
+
26
+ import httpx
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # ──────────────────────────────────────────────────────────────────────
31
+ # Constants
32
+ # ──────────────────────────────────────────────────────────────────────
33
+ TG_API = "https://api.telegram.org/bot{token}/{method}"
34
+ TG_FILE = "https://api.telegram.org/file/bot{token}/{file_path}"
35
+
36
+ # Telegram hard limit for getFile downloads via Bot API is 20 MB.
37
+ # Files larger than this must be sent as separate parts (chunking) or
38
+ # via a Telegram client (MTProto). We warn but still attempt.
39
+ TG_MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
40
+
41
+ TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=120.0, pool=10.0)
42
+
43
+
44
+ # ──────────────────────────────────────────────────────────────────────
45
+ # Shared async HTTP client (one per process)
46
+ # ──────────────────────────────────────────────────────────────────────
47
+ _http: httpx.AsyncClient | None = None
48
+
49
+ def _client() -> httpx.AsyncClient:
50
+ global _http
51
+ if _http is None or _http.is_closed:
52
+ _http = httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=True)
53
+ return _http
54
+
55
+
56
+ async def close_http():
57
+ """Call on app shutdown to cleanly drain the connection pool."""
58
+ global _http
59
+ if _http and not _http.is_closed:
60
+ await _http.aclose()
61
+ _http = None
62
+
63
+
64
+ # ──────────────────────────────────────────────────────────────────────
65
+ # Bot pool
66
+ # ──────────────────────────────────────────────────────────────────────
67
+ _pool: list[dict] = [] # [{"token": str, "username": str, "id": int}, …]
68
+ _cycle: itertools.cycle | None = None
69
+
70
+
71
+ def _tokens_path() -> Path:
72
+ """Look for tokens.txt next to this file, then in cwd."""
73
+ for candidate in [Path(__file__).parent / "tokens.txt",
74
+ Path(os.getcwd()) / "tokens.txt"]:
75
+ if candidate.exists():
76
+ return candidate
77
+ raise FileNotFoundError(
78
+ "tokens.txt not found. Create it with one bot token per line.\n"
79
+ "Example: 123456789:AAExampleTokenHere"
80
+ )
81
+
82
+
83
+ async def _verify_token(token: str) -> dict | None:
84
+ """Call getMe to validate a token. Returns bot info dict or None."""
85
+ url = TG_API.format(token=token, method="getMe")
86
+ try:
87
+ r = await _client().get(url)
88
+ data = r.json()
89
+ if data.get("ok"):
90
+ bot = data["result"]
91
+ return {"token": token, "username": bot["username"], "id": bot["id"]}
92
+ logger.warning(f"βœ— Token rejected by Telegram ({token[:20]}…): {data.get('description')}")
93
+ except Exception as e:
94
+ logger.warning(f"βœ— Could not reach Telegram for token {token[:20]}…: {e}")
95
+ return None
96
+
97
+
98
+ async def init_bot_pool():
99
+ """
100
+ Read tokens.txt, verify each token with getMe(), build the round-robin pool.
101
+ Raises RuntimeError if no healthy bots are found.
102
+ """
103
+ global _pool, _cycle
104
+
105
+ path = _tokens_path()
106
+ raw_tokens = [
107
+ line.strip()
108
+ for line in path.read_text(encoding="utf-8").splitlines()
109
+ if line.strip() and not line.startswith("#")
110
+ ]
111
+
112
+ if not raw_tokens:
113
+ raise RuntimeError(f"tokens.txt at {path} is empty β€” add at least one bot token.")
114
+
115
+ healthy = []
116
+ for token in raw_tokens:
117
+ info = await _verify_token(token)
118
+ if info:
119
+ logger.info(f"βœ“ Bot ready: @{info['username']} (id={info['id']})")
120
+ healthy.append(info)
121
+
122
+ if not healthy:
123
+ raise RuntimeError(
124
+ "No healthy bots found.\n"
125
+ "β€’ Check tokens.txt β€” each line must be a valid BotFather token.\n"
126
+ "β€’ The bot must be added as an Administrator to your CHANNEL_ID."
127
+ )
128
+
129
+ _pool = healthy
130
+ _cycle = itertools.cycle(_pool)
131
+ logger.info(f"Bot pool ready β€” {len(_pool)} bot(s) active.")
132
+
133
+
134
+ def _next_bot() -> dict:
135
+ if not _pool:
136
+ raise RuntimeError(
137
+ "Bot pool is empty. Make sure init_bot_pool() ran at startup "
138
+ "and tokens.txt contains at least one valid token."
139
+ )
140
+ return next(_cycle) # type: ignore[arg-type]
141
+
142
+
143
+ def _get_channel_id() -> int:
144
+ raw = os.getenv("CHANNEL_ID", "0").strip()
145
+ if not raw or raw == "0":
146
+ raise RuntimeError(
147
+ "CHANNEL_ID is not set.\n"
148
+ "Add to .env: CHANNEL_ID=-1001234567890\n"
149
+ "Tip: forward any message from the channel to @JsonDumpBot to get the ID."
150
+ )
151
+ try:
152
+ return int(raw)
153
+ except ValueError:
154
+ raise RuntimeError(f"CHANNEL_ID must be an integer, got: {raw!r}")
155
+
156
+
157
+ # ──────────────────────────────────────────────────────────────────────
158
+ # Low-level API helpers
159
+ # ──────────────────────────────────────────────────────────────────────
160
+
161
+ async def _api(token: str, method: str, **kwargs) -> dict:
162
+ """
163
+ POST to a Bot API method with JSON body.
164
+ Raises RuntimeError on non-ok responses.
165
+ """
166
+ url = TG_API.format(token=token, method=method)
167
+ r = await _client().post(url, **kwargs)
168
+ data = r.json()
169
+ if not data.get("ok"):
170
+ raise RuntimeError(
171
+ f"Telegram API error on {method}: "
172
+ f"[{data.get('error_code')}] {data.get('description')}"
173
+ )
174
+ return data["result"]
175
+
176
+
177
+ # ──────────────────────────────────────────────────────────────────────
178
+ # Upload
179
+ # ──────────────────────────────────────────────────────────────────────
180
+
181
+ async def upload_to_telegram(
182
+ content: bytes,
183
+ filename: str,
184
+ mime_type: str,
185
+ ) -> Tuple[int, str]:
186
+ """
187
+ Upload raw bytes to the Telegram channel as a document.
188
+ Returns: (message_id, tg_file_id)
189
+ """
190
+ channel_id = _get_channel_id()
191
+ bot = _next_bot()
192
+
193
+ files = {"document": (filename, io.BytesIO(content), mime_type)}
194
+ payload = {
195
+ "chat_id": channel_id,
196
+ "caption": f"πŸ“ {filename} β€’ {mime_type} β€’ {len(content):,} B",
197
+ }
198
+
199
+ try:
200
+ msg = await _api(
201
+ bot["token"], "sendDocument",
202
+ data=payload,
203
+ files=files,
204
+ )
205
+ except RuntimeError as e:
206
+ raise RuntimeError(
207
+ f"{e}\n"
208
+ f"Bot: @{bot['username']} | Channel: {channel_id}\n"
209
+ f"Make sure the bot is an Administrator in the channel."
210
+ )
211
+
212
+ doc = msg["document"]
213
+ file_id = doc["file_id"]
214
+ message_id = msg["message_id"]
215
+
216
+ logger.info(f"Uploaded {filename!r} β†’ msg_id={message_id} file_id={file_id[:24]}…")
217
+ return message_id, file_id
218
+
219
+
220
+ # ──────────────────────────────────────────────────────────────────────
221
+ # Download
222
+ # ──────────────────────────────────────────────────────────────────────
223
+
224
+ async def download_from_telegram(
225
+ tg_message_id: int,
226
+ tg_file_id: str | None,
227
+ ) -> bytes:
228
+ """
229
+ Download and return the raw bytes of a stored file.
230
+
231
+ Strategy:
232
+ 1. Call getFile(file_id) to resolve the temporary download path.
233
+ 2. GET the file bytes from the CDN path.
234
+ 3. If step 1 fails (file_id stale), fall back to forwarding the
235
+ original message and re-extracting the document's file_id.
236
+ """
237
+ channel_id = _get_channel_id()
238
+ bot = _next_bot()
239
+
240
+ # ── Stage 1: resolve download path ──────────────────────────────
241
+ file_path: str | None = None
242
+
243
+ if tg_file_id:
244
+ try:
245
+ result = await _api(bot["token"], "getFile", json={"file_id": tg_file_id})
246
+ file_path = result.get("file_path")
247
+ except RuntimeError as e:
248
+ logger.warning(f"getFile failed for file_id {tg_file_id[:24]}…, trying message fallback. ({e})")
249
+
250
+ # ── Stage 2: message fallback if file_id is stale ───────────────
251
+ if not file_path:
252
+ try:
253
+ fwd = await _api(bot["token"], "forwardMessage", json={
254
+ "chat_id": channel_id,
255
+ "from_chat_id": channel_id,
256
+ "message_id": tg_message_id,
257
+ })
258
+ except RuntimeError as e:
259
+ raise RuntimeError(
260
+ f"Could not retrieve message {tg_message_id} from channel {channel_id}.\n"
261
+ f"Ensure the bot can read the channel. Detail: {e}"
262
+ )
263
+
264
+ doc = fwd.get("document")
265
+ if not doc:
266
+ raise ValueError(f"Message {tg_message_id} contains no document.")
267
+
268
+ result = await _api(bot["token"], "getFile", json={"file_id": doc["file_id"]})
269
+ file_path = result.get("file_path")
270
+
271
+ if not file_path:
272
+ raise RuntimeError("Telegram did not return a file_path β€” file may be too large for Bot API (>20 MB).")
273
+
274
+ # ── Stage 3: download bytes ──────────────────────────────────────
275
+ url = TG_FILE.format(token=bot["token"], file_path=file_path)
276
+ r = await _client().get(url)
277
+
278
+ if r.status_code != 200:
279
+ raise RuntimeError(f"File download failed: HTTP {r.status_code} from Telegram CDN.")
280
+
281
+ logger.info(f"Downloaded {len(r.content):,} bytes for msg_id={tg_message_id}")
282
+ return r.content
tokens.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 6637167867:AAEDDm3d2l5bjD-Ms7QJ3WnEPu7KKWLY86w
vercel.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 2,
3
+ "builds": [
4
+ {
5
+ "src": "server.py",
6
+ "use": "@vercel/python"
7
+ }
8
+ ],
9
+ "routes": [
10
+ {
11
+ "src": "/(.*)",
12
+ "dest": "server.py"
13
+ }
14
+ ]
15
+ }