NitinBot001 commited on
Commit
a13a754
Β·
verified Β·
1 Parent(s): 267c680

Upload 14 files

Browse files
.env CHANGED
@@ -6,10 +6,9 @@ API_HASH=51225833a1b50c15c6c39071ea567e48
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=
 
6
  # Use negative ID for supergroups: e.g. -1001234567890
7
  CHANNEL_ID=-1003739341703
8
 
9
+ # Supabase (get from: Supabase Dashboard β†’ Settings β†’ API)
10
+ SUPABASE_URL=https://geslknqmelmaoowdkmwv.supabase.co
11
+ SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdlc2xrbnFtZWxtYW9vd2RrbXd2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTIzMjAwMzMsImV4cCI6MjA2Nzg5NjAzM30.k2JhLyjZ5F6W1vwwGX2yqPf-1Q8vm6nQyV8Zhqfqtfc
 
12
 
13
  # API authentication key for this server
14
  ADMIN_API_KEY=
.gitignore CHANGED
@@ -1,3 +0,0 @@
1
- .env
2
- __pycache__
3
- .gitignore
 
 
 
 
Dockerfile CHANGED
@@ -1,35 +1,35 @@
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
-
8
- # Set the working directory in the container
9
- WORKDIR /app
10
-
11
- # Install system dependencies required for some Python packages
12
- RUN apt-get update && apt-get install -y --no-install-recommends \
13
- gcc \
14
- python3-dev \
15
- && rm -rf /var/lib/apt/lists/*
16
-
17
- # Copy requirements first to leverage Docker cache
18
- COPY requirements.txt .
19
-
20
- # Install Python dependencies
21
- RUN pip install --no-cache-dir -r requirements.txt
22
-
23
- # Create a non-root user and switch to it
24
- RUN groupadd -r appuser && useradd -r -g appuser appuser
25
- RUN chown -R appuser:appuser /app
26
- USER appuser
27
-
28
- # Copy the rest of the application
29
- COPY --chown=appuser:appuser . .
30
-
31
- # Expose the port the app runs on
32
- EXPOSE 8082
33
-
34
- # Command to run the application
35
- CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8082"]
 
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
+
8
+ # Set the working directory in the container
9
+ WORKDIR /app
10
+
11
+ # Install system dependencies required for some Python packages
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ gcc \
14
+ python3-dev \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements first to leverage Docker cache
18
+ COPY requirements.txt .
19
+
20
+ # Install Python dependencies
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Create a non-root user and switch to it
24
+ RUN groupadd -r appuser && useradd -r -g appuser appuser
25
+ RUN chown -R appuser:appuser /app
26
+ USER appuser
27
+
28
+ # Copy the rest of the application
29
+ COPY --chown=appuser:appuser . .
30
+
31
+ # Expose the port the app runs on
32
+ EXPOSE 8082
33
+
34
+ # Command to run the application
35
+ CMD ["gunicorn", "main:app", "-w", "4", "--bind", "0.0.0.0:8082"]
__pycache__/db.cpython-313.pyc ADDED
Binary file (5.3 kB). View file
 
__pycache__/main.cpython-313.pyc ADDED
Binary file (11.2 kB). View file
 
__pycache__/tg.cpython-313.pyc ADDED
Binary file (11.3 kB). View file
 
db.py CHANGED
@@ -1,42 +1,59 @@
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,
@@ -47,8 +64,8 @@ async def save_file_record(
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,
@@ -56,39 +73,63 @@ async def save_file_record(
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({})
 
 
 
 
 
 
1
  """
2
+ db.py β€” Supabase (PostgreSQL) metadata store.
3
+ Table: files
4
+
5
+ Before first run, create the table in Supabase SQL Editor:
6
+
7
+ CREATE TABLE IF NOT EXISTS files (
8
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
9
+ file_id TEXT UNIQUE NOT NULL,
10
+ filename TEXT NOT NULL,
11
+ mime_type TEXT NOT NULL,
12
+ size_bytes BIGINT NOT NULL,
13
+ tg_message_id BIGINT NOT NULL,
14
+ tg_file_id TEXT,
15
+ public_url TEXT NOT NULL,
16
+ custom_path TEXT UNIQUE,
17
+ uploaded_at TIMESTAMPTZ DEFAULT now()
18
+ );
19
  """
20
 
21
  import os
22
+ from datetime import datetime, timezone
23
  from typing import Optional
24
 
25
+ from supabase import create_client, Client
 
26
 
27
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
28
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
29
 
30
+ _supabase: Optional[Client] = None
31
 
32
+ TABLE = "files"
33
 
 
 
 
 
 
34
 
35
+ def _get_client() -> Client:
36
+ global _supabase
37
+ if _supabase is None:
38
+ if not SUPABASE_URL or not SUPABASE_KEY:
39
+ raise RuntimeError(
40
+ "SUPABASE_URL and SUPABASE_KEY must be set in environment / .env"
41
+ )
42
+ _supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
43
+ return _supabase
44
 
45
+
46
+ def init_db():
47
+ """Verify Supabase connection by performing a lightweight query."""
48
+ client = _get_client()
49
+ client.table(TABLE).select("file_id").limit(1).execute()
 
 
50
 
51
 
52
  # ──────────────────────────────────────────────────
53
  # CRUD
54
  # ──────────────────────────────────────────────────
55
 
56
+ def save_file_record(
57
  *,
58
  file_id: str,
59
  filename: str,
 
64
  public_url: str,
65
  custom_path: str | None = None,
66
  ):
67
+ client = _get_client()
68
+ row = {
69
  "file_id": file_id,
70
  "filename": filename,
71
  "mime_type": mime_type,
 
73
  "tg_message_id": tg_message_id,
74
  "tg_file_id": tg_file_id,
75
  "public_url": public_url,
76
+ "uploaded_at": datetime.now(timezone.utc).isoformat(),
77
  }
78
  if custom_path:
79
+ row["custom_path"] = custom_path
80
+ client.table(TABLE).insert(row).execute()
81
+
82
+
83
+ def get_file_record(file_id: str) -> dict | None:
84
+ client = _get_client()
85
+ resp = (
86
+ client.table(TABLE)
87
+ .select("*")
88
+ .eq("file_id", file_id)
89
+ .limit(1)
90
+ .execute()
91
+ )
92
+ if resp.data:
93
+ return resp.data[0]
94
+ return None
95
+
96
+
97
+ def get_file_by_custom_path(custom_path: str) -> dict | None:
98
+ client = _get_client()
99
+ resp = (
100
+ client.table(TABLE)
101
+ .select("*")
102
+ .eq("custom_path", custom_path)
103
+ .limit(1)
104
+ .execute()
105
  )
106
+ if resp.data:
107
+ return resp.data[0]
108
+ return None
109
+
110
+
111
+ def list_file_records(limit: int = 50, offset: int = 0) -> list[dict]:
112
+ client = _get_client()
113
+ resp = (
114
+ client.table(TABLE)
115
+ .select("*")
116
+ .order("uploaded_at", desc=True)
117
+ .range(offset, offset + limit - 1)
118
+ .execute()
119
+ )
120
+ return resp.data or []
121
 
122
 
123
+ def delete_file_record(file_id: str):
124
+ client = _get_client()
125
+ client.table(TABLE).delete().eq("file_id", file_id).execute()
126
 
127
 
128
+ def count_files() -> int:
129
+ client = _get_client()
130
+ resp = (
131
+ client.table(TABLE)
132
+ .select("file_id", count="exact")
133
+ .execute()
134
+ )
135
+ return resp.count or 0
main.py CHANGED
@@ -4,32 +4,29 @@ TG Storage API β€” Store & retrieve files via Telegram as a backend.
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,
@@ -38,32 +35,11 @@ from db import (
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("/")
@@ -71,57 +47,62 @@ BASE_URL = os.getenv("BASE_URL", "http://localhost:8082").rstrip("/")
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
  },
@@ -132,100 +113,87 @@ async def _stream_record(record: dict) -> StreamingResponse:
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,
@@ -238,7 +206,7 @@ async def upload_file(
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,
@@ -247,26 +215,29 @@ async def upload_file(
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)),
@@ -275,22 +246,29 @@ async def download_file(file_id: str, _: str = Depends(require_api_key)):
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}
 
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 re
19
  import uuid
20
  import logging
21
  import mimetypes
22
+ import atexit
23
+ from datetime import datetime, timezone
 
24
  from pathlib import Path
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
+ from flask import Flask, request, jsonify, Response, abort, send_file
29
+ from flask_cors import CORS
 
30
 
31
  from db import (
32
  init_db, save_file_record,
 
35
  )
36
  from tg import upload_to_telegram, download_from_telegram, init_bot_pool, close_http
37
 
 
 
 
 
 
 
 
 
 
 
38
  # ──────────────────────────────────────────────────
39
  # App
40
  # ──────────────────────────────────────────────────
41
+ app = Flask(__name__)
42
+ CORS(app)
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "changeme")
45
  BASE_URL = os.getenv("BASE_URL", "http://localhost:8082").rstrip("/")
 
47
  _HERE = Path(__file__).parent
48
  FRONTEND_PATH = _HERE / "frontend.html"
49
 
50
+ # Allowed characters in a custom path segment
 
51
  _CUSTOM_PATH_RE = re.compile(r'^[a-zA-Z0-9._\-/]+$')
52
 
53
+ # ──────────────────────────────────────────────────
54
+ # Startup / Shutdown
55
+ # ──────────────────────────────────────────────────
56
+ _initialized = False
57
+
58
+ def _startup():
59
+ global _initialized
60
+ if _initialized:
61
+ return
62
+ init_db() # connect Supabase + verify table exists
63
+ init_bot_pool() # verify tokens.txt & build bot pool
64
+ _initialized = True
65
+
66
+ atexit.register(close_http) # drain httpx connection pool on exit
67
+
68
 
69
  # ──────────────────────────────────────────────────
70
  # Helpers
71
  # ──────────────────────────────────────────────────
72
+ def require_api_key():
73
+ key = request.headers.get("X-API-Key", "")
74
+ if key != ADMIN_API_KEY:
75
+ abort(401, description="Invalid or missing API key")
76
 
77
 
78
  def _sanitize_custom_path(raw: str) -> str:
 
 
 
 
 
79
  path = raw.strip().strip("/")
80
  if not path:
81
+ abort(400, description="custom_path cannot be empty after stripping slashes.")
82
  if ".." in path:
83
+ abort(400, description="custom_path must not contain '..'")
84
  if not _CUSTOM_PATH_RE.match(path):
85
+ abort(400, description="custom_path may only contain letters, digits, hyphens, underscores, dots, and slashes.")
 
 
 
86
  return path
87
 
88
 
89
  def _build_public_url(identifier: str) -> str:
 
90
  return f"{BASE_URL}/cdn/{identifier}"
91
 
92
 
93
+ def _make_stream_response(record: dict, disposition: str = "inline") -> Response:
94
+ """Download from Telegram and return to client."""
95
  try:
96
+ data: bytes = download_from_telegram(record["tg_message_id"], record["tg_file_id"])
97
  except Exception as exc:
98
  logger.exception("Telegram download error")
99
+ abort(502, description=str(exc))
100
 
101
+ return Response(
102
+ data,
103
+ mimetype=record["mime_type"],
104
  headers={
105
+ "Content-Disposition": f'{disposition}; filename="{record["filename"]}"',
106
  "Content-Length": str(len(data)),
107
  "Cache-Control": "public, max-age=31536000, immutable",
108
  },
 
113
  # Routes
114
  # ──────────────────────────────────────────────────
115
 
116
+ @app.route("/")
117
+ def frontend():
118
  if FRONTEND_PATH.exists():
119
+ return Response(FRONTEND_PATH.read_text(encoding="utf-8"), mimetype="text/html")
120
+ return Response("<h2>frontend.html not found</h2>", status=404, mimetype="text/html")
121
 
122
 
123
+ @app.route("/health")
124
+ def health():
125
+ _startup()
126
+ total = count_files()
127
+ return jsonify({
128
+ "status": "ok",
129
+ "timestamp": datetime.now(timezone.utc).isoformat(),
130
+ "total_files": total,
131
+ "base_url": BASE_URL,
132
+ })
133
 
134
 
135
  # ── CDN β€” public, no auth ─────────────────────────────────────────────
136
+ @app.route("/cdn/<path:path>")
137
+ def cdn_file(path: str):
138
+ _startup()
 
 
 
 
 
 
 
 
139
  # 1 β€” custom path lookup
140
+ record = get_file_by_custom_path(path)
141
 
142
  # 2 β€” fall back to file_id lookup
143
  if not record:
144
+ record = get_file_record(path)
145
 
146
  if not record:
147
+ return jsonify({
148
+ "detail": f"No file found for path '{path}'. "
149
+ f"Provide a valid file_id or a custom_path assigned at upload."
150
+ }), 404
 
151
 
152
+ return _make_stream_response(record)
153
 
154
 
155
  # ── Upload ────────────────────────────────────────────────────────────
156
+ @app.route("/upload", methods=["POST"])
157
+ def upload_file_route():
158
+ _startup()
159
+ require_api_key()
160
+
161
+ if "file" not in request.files:
162
+ return jsonify({"detail": "No file provided."}), 400
163
+
164
+ file = request.files["file"]
165
+ content = file.read()
 
 
 
 
 
 
 
 
166
  if not content:
167
+ return jsonify({"detail": "Empty file."}), 400
168
 
169
  filename = file.filename or f"upload_{uuid.uuid4().hex}"
170
  mime_type = file.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
171
  size = len(content)
172
 
173
  # Validate + normalise custom_path if provided
174
+ clean_custom_path = None
175
+ custom_path_raw = request.form.get("custom_path", "")
176
+ if custom_path_raw and custom_path_raw.strip():
177
+ clean_custom_path = _sanitize_custom_path(custom_path_raw)
178
+ existing = get_file_by_custom_path(clean_custom_path)
179
  if existing:
180
+ return jsonify({
181
+ "detail": f"custom_path '{clean_custom_path}' is already taken by file_id={existing['file_id']}."
182
+ }), 409
 
183
 
184
  # Upload bytes to Telegram
185
  try:
186
+ tg_message_id, tg_file_id = upload_to_telegram(content, filename, mime_type)
187
  except Exception as exc:
188
  logger.exception("Telegram upload error")
189
+ return jsonify({"detail": str(exc)}), 502
190
 
191
  # Build URLs
192
  file_id = str(uuid.uuid4())
193
  cdn_key = clean_custom_path if clean_custom_path else file_id
194
  public_url = _build_public_url(cdn_key)
195
 
196
+ save_file_record(
197
  file_id=file_id,
198
  filename=filename,
199
  mime_type=mime_type,
 
206
 
207
  logger.info(f"Uploaded {filename!r} β†’ {public_url}")
208
 
209
+ return jsonify({
210
  "file_id": file_id,
211
  "filename": filename,
212
  "mime_type": mime_type,
 
215
  "public_url": public_url,
216
  "cdn_url_by_id": _build_public_url(file_id),
217
  "cdn_url_by_path": _build_public_url(clean_custom_path) if clean_custom_path else None,
218
+ "uploaded_at": datetime.now(timezone.utc).isoformat(),
219
+ })
220
 
221
 
222
  # ── Authenticated download ────────────────────────────────────────────
223
+ @app.route("/file/<file_id>", methods=["GET"])
224
+ def download_file_route(file_id: str):
225
+ _startup()
226
+ require_api_key()
227
+
228
+ record = get_file_record(file_id)
229
  if not record:
230
+ return jsonify({"detail": "File not found."}), 404
231
 
232
  try:
233
+ data: bytes = download_from_telegram(record["tg_message_id"], record["tg_file_id"])
234
  except Exception as exc:
235
  logger.exception("Download error")
236
+ return jsonify({"detail": str(exc)}), 502
237
 
238
+ return Response(
239
+ data,
240
+ mimetype=record["mime_type"],
241
  headers={
242
  "Content-Disposition": f'attachment; filename="{record["filename"]}"',
243
  "Content-Length": str(len(data)),
 
246
 
247
 
248
  # ── List ──────────────────────────────────────────────────────────────
249
+ @app.route("/files")
250
+ def list_files_route():
251
+ _startup()
252
+ require_api_key()
253
+
254
+ limit = request.args.get("limit", 50, type=int)
255
+ offset = request.args.get("offset", 0, type=int)
256
+ limit = max(1, min(limit, 500))
257
+ offset = max(0, offset)
258
+
259
+ records = list_file_records(limit=limit, offset=offset)
260
+ total = count_files()
261
+ return jsonify({"total": total, "limit": limit, "offset": offset, "files": records})
262
 
263
 
264
  # ── Delete ────────────────────────────────────────────────────────────
265
+ @app.route("/file/<file_id>", methods=["DELETE"])
266
+ def delete_file_route(file_id: str):
267
+ _startup()
268
+ require_api_key()
269
+
270
+ record = get_file_record(file_id)
271
  if not record:
272
+ return jsonify({"detail": "File not found."}), 404
273
+ delete_file_record(file_id)
274
+ return jsonify({"deleted": True, "file_id": file_id})
requirements.txt CHANGED
@@ -1,8 +1,6 @@
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
8
- gunicorn
 
1
+ flask>=3.0.0
2
+ flask-cors>=4.0.0
 
3
  httpx>=0.27.0
4
+ supabase>=2.0.0
 
5
  python-dotenv>=1.0.1
6
+ gunicorn
server.py CHANGED
@@ -1,27 +1,19 @@
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
  )
 
1
  """
2
+ server.py β€” Entry point. Loads .env then starts Flask.
3
 
4
  Run:
5
  python server.py
 
 
6
  """
7
 
 
 
8
  from dotenv import load_dotenv
9
 
10
  load_dotenv() # load .env before importing app so env vars are available
11
 
12
+ from main import app
 
 
 
13
 
14
+ if __name__ == "__main__":
15
+ app.run(
16
  host="0.0.0.0",
17
  port=8082,
18
+ debug=True,
 
19
  )
tg.py CHANGED
@@ -8,7 +8,7 @@ Bot pool:
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.
@@ -34,30 +34,28 @@ 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
 
@@ -80,11 +78,11 @@ def _tokens_path() -> Path:
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"]
@@ -95,7 +93,7 @@ async def _verify_token(token: str) -> dict | None:
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.
@@ -114,7 +112,7 @@ async def init_bot_pool():
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)
@@ -158,13 +156,13 @@ def _get_channel_id() -> int:
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(
@@ -178,7 +176,7 @@ async def _api(token: str, method: str, **kwargs) -> dict:
178
  # Upload
179
  # ──────────────────────────────────────────────────────────────────────
180
 
181
- async def upload_to_telegram(
182
  content: bytes,
183
  filename: str,
184
  mime_type: str,
@@ -197,7 +195,7 @@ async def upload_to_telegram(
197
  }
198
 
199
  try:
200
- msg = await _api(
201
  bot["token"], "sendDocument",
202
  data=payload,
203
  files=files,
@@ -221,7 +219,7 @@ async def upload_to_telegram(
221
  # Download
222
  # ──────────────────────────────────────────────────────────────────────
223
 
224
- async def download_from_telegram(
225
  tg_message_id: int,
226
  tg_file_id: str | None,
227
  ) -> bytes:
@@ -242,7 +240,7 @@ async def download_from_telegram(
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})")
@@ -250,7 +248,7 @@ async def download_from_telegram(
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,
@@ -265,7 +263,7 @@ async def download_from_telegram(
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:
@@ -273,7 +271,7 @@ async def download_from_telegram(
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.")
 
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 Supabase.
12
 
13
  Download flow (two-stage):
14
  1. getFile(file_id) β†’ get a temporary download path from Telegram.
 
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
  TG_MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
38
 
39
  TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=120.0, pool=10.0)
40
 
41
 
42
  # ──────────────────────────────────────────────────────────────────────
43
+ # Shared sync HTTP client (one per process)
44
  # ──────────────────────────────────────────────────────────────────────
45
+ _http: httpx.Client | None = None
46
 
47
+ def _client() -> httpx.Client:
48
  global _http
49
  if _http is None or _http.is_closed:
50
+ _http = httpx.Client(timeout=TIMEOUT, follow_redirects=True)
51
  return _http
52
 
53
 
54
+ def close_http():
55
  """Call on app shutdown to cleanly drain the connection pool."""
56
  global _http
57
  if _http and not _http.is_closed:
58
+ _http.close()
59
  _http = None
60
 
61
 
 
78
  )
79
 
80
 
81
+ def _verify_token(token: str) -> dict | None:
82
  """Call getMe to validate a token. Returns bot info dict or None."""
83
  url = TG_API.format(token=token, method="getMe")
84
  try:
85
+ r = _client().get(url)
86
  data = r.json()
87
  if data.get("ok"):
88
  bot = data["result"]
 
93
  return None
94
 
95
 
96
+ def init_bot_pool():
97
  """
98
  Read tokens.txt, verify each token with getMe(), build the round-robin pool.
99
  Raises RuntimeError if no healthy bots are found.
 
112
 
113
  healthy = []
114
  for token in raw_tokens:
115
+ info = _verify_token(token)
116
  if info:
117
  logger.info(f"βœ“ Bot ready: @{info['username']} (id={info['id']})")
118
  healthy.append(info)
 
156
  # Low-level API helpers
157
  # ──────────────────────────────────────────────────────────────────────
158
 
159
+ def _api(token: str, method: str, **kwargs) -> dict:
160
  """
161
  POST to a Bot API method with JSON body.
162
  Raises RuntimeError on non-ok responses.
163
  """
164
  url = TG_API.format(token=token, method=method)
165
+ r = _client().post(url, **kwargs)
166
  data = r.json()
167
  if not data.get("ok"):
168
  raise RuntimeError(
 
176
  # Upload
177
  # ──────────────────────────────────────────────────────────────────────
178
 
179
+ def upload_to_telegram(
180
  content: bytes,
181
  filename: str,
182
  mime_type: str,
 
195
  }
196
 
197
  try:
198
+ msg = _api(
199
  bot["token"], "sendDocument",
200
  data=payload,
201
  files=files,
 
219
  # Download
220
  # ──────────────────────────────────────────────────────────────────────
221
 
222
+ def download_from_telegram(
223
  tg_message_id: int,
224
  tg_file_id: str | None,
225
  ) -> bytes:
 
240
 
241
  if tg_file_id:
242
  try:
243
+ result = _api(bot["token"], "getFile", json={"file_id": tg_file_id})
244
  file_path = result.get("file_path")
245
  except RuntimeError as e:
246
  logger.warning(f"getFile failed for file_id {tg_file_id[:24]}…, trying message fallback. ({e})")
 
248
  # ── Stage 2: message fallback if file_id is stale ───────────────
249
  if not file_path:
250
  try:
251
+ fwd = _api(bot["token"], "forwardMessage", json={
252
  "chat_id": channel_id,
253
  "from_chat_id": channel_id,
254
  "message_id": tg_message_id,
 
263
  if not doc:
264
  raise ValueError(f"Message {tg_message_id} contains no document.")
265
 
266
+ result = _api(bot["token"], "getFile", json={"file_id": doc["file_id"]})
267
  file_path = result.get("file_path")
268
 
269
  if not file_path:
 
271
 
272
  # ── Stage 3: download bytes ──────────────────────────────────────
273
  url = TG_FILE.format(token=bot["token"], file_path=file_path)
274
+ r = _client().get(url)
275
 
276
  if r.status_code != 200:
277
  raise RuntimeError(f"File download failed: HTTP {r.status_code} from Telegram CDN.")