Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- .env +16 -0
- .gitignore +3 -0
- Dockerfile +37 -0
- README.md +423 -1
- db.py +94 -0
- frontend.html +874 -0
- main.py +296 -0
- requirements.txt +7 -0
- server.py +27 -0
- tg.py +282 -0
- tokens.txt +1 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](https://python.org)
|
| 17 |
+
[](https://fastapi.tiangolo.com)
|
| 18 |
+
[](https://www.mongodb.com/atlas)
|
| 19 |
+
[](LICENSE)
|
| 20 |
+
[](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 Β· 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 Β· 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 |
+
}
|