Spaces:
Sleeping
Sleeping
Upload 14 files
Browse files- .env +3 -4
- .gitignore +0 -3
- Dockerfile +35 -35
- __pycache__/db.cpython-313.pyc +0 -0
- __pycache__/main.cpython-313.pyc +0 -0
- __pycache__/tg.cpython-313.pyc +0 -0
- db.py +93 -52
- main.py +123 -145
- requirements.txt +4 -6
- server.py +5 -13
- tg.py +20 -22
.env
CHANGED
|
@@ -6,10 +6,9 @@ API_HASH=51225833a1b50c15c6c39071ea567e48
|
|
| 6 |
# Use negative ID for supergroups: e.g. -1001234567890
|
| 7 |
CHANNEL_ID=-1003739341703
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
| 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", "-
|
|
|
|
| 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 β
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
| 7 |
-
from datetime import datetime
|
| 8 |
from typing import Optional
|
| 9 |
|
| 10 |
-
from
|
| 11 |
-
from pymongo import DESCENDING
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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 |
-
|
| 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 |
-
|
| 51 |
-
|
| 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.
|
| 60 |
}
|
| 61 |
if custom_path:
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
)
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 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/
|
| 12 |
GET /files β List all stored files
|
| 13 |
-
DELETE /file/
|
| 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 |
-
|
| 24 |
-
from datetime import datetime
|
| 25 |
-
from typing import Optional
|
| 26 |
from pathlib import Path
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
-
from
|
| 31 |
-
from
|
| 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 =
|
| 55 |
-
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 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 |
-
|
| 97 |
if ".." in path:
|
| 98 |
-
|
| 99 |
if not _CUSTOM_PATH_RE.match(path):
|
| 100 |
-
|
| 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 |
-
|
| 113 |
-
"""Download from Telegram and
|
| 114 |
try:
|
| 115 |
-
data: bytes =
|
| 116 |
except Exception as exc:
|
| 117 |
logger.exception("Telegram download error")
|
| 118 |
-
|
| 119 |
|
| 120 |
-
return
|
| 121 |
-
|
| 122 |
-
|
| 123 |
headers={
|
| 124 |
-
"Content-Disposition": f'
|
| 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.
|
| 136 |
-
|
| 137 |
if FRONTEND_PATH.exists():
|
| 138 |
-
return
|
| 139 |
-
return
|
| 140 |
|
| 141 |
|
| 142 |
-
@app.
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
# ββ CDN β public, no auth βββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
-
@app.
|
| 151 |
-
|
| 152 |
-
|
| 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 =
|
| 163 |
|
| 164 |
# 2 β fall back to file_id lookup
|
| 165 |
if not record:
|
| 166 |
-
record =
|
| 167 |
|
| 168 |
if not record:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
)
|
| 174 |
|
| 175 |
-
return
|
| 176 |
|
| 177 |
|
| 178 |
# ββ Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 179 |
-
@app.
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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 |
-
|
| 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
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
existing =
|
| 210 |
if existing:
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
)
|
| 215 |
|
| 216 |
# Upload bytes to Telegram
|
| 217 |
try:
|
| 218 |
-
tg_message_id, tg_file_id =
|
| 219 |
except Exception as exc:
|
| 220 |
logger.exception("Telegram upload error")
|
| 221 |
-
|
| 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 |
-
|
| 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.
|
| 251 |
-
}
|
| 252 |
|
| 253 |
|
| 254 |
# ββ Authenticated download ββββββββββββββββββββββββββββββββββββββββββββ
|
| 255 |
-
@app.
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
| 258 |
if not record:
|
| 259 |
-
|
| 260 |
|
| 261 |
try:
|
| 262 |
-
data: bytes =
|
| 263 |
except Exception as exc:
|
| 264 |
logger.exception("Download error")
|
| 265 |
-
|
| 266 |
|
| 267 |
-
return
|
| 268 |
-
|
| 269 |
-
|
| 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.
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
# ββ Delete ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 290 |
-
@app.
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
| 293 |
if not record:
|
| 294 |
-
|
| 295 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
python-multipart>=0.0.9
|
| 4 |
httpx>=0.27.0
|
| 5 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 22 |
-
|
| 23 |
host="0.0.0.0",
|
| 24 |
port=8082,
|
| 25 |
-
|
| 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
|
| 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
|
| 46 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
-
_http: httpx.
|
| 48 |
|
| 49 |
-
def _client() -> httpx.
|
| 50 |
global _http
|
| 51 |
if _http is None or _http.is_closed:
|
| 52 |
-
_http = httpx.
|
| 53 |
return _http
|
| 54 |
|
| 55 |
|
| 56 |
-
|
| 57 |
"""Call on app shutdown to cleanly drain the connection pool."""
|
| 58 |
global _http
|
| 59 |
if _http and not _http.is_closed:
|
| 60 |
-
|
| 61 |
_http = None
|
| 62 |
|
| 63 |
|
|
@@ -80,11 +78,11 @@ def _tokens_path() -> Path:
|
|
| 80 |
)
|
| 81 |
|
| 82 |
|
| 83 |
-
|
| 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 =
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 =
|
| 201 |
bot["token"], "sendDocument",
|
| 202 |
data=payload,
|
| 203 |
files=files,
|
|
@@ -221,7 +219,7 @@ async def upload_to_telegram(
|
|
| 221 |
# Download
|
| 222 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
|
| 224 |
-
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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.")
|