Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
-
TG Storage API
|
| 3 |
|
| 4 |
Endpoints:
|
| 5 |
-
GET /
|
| 6 |
-
POST /upload
|
| 7 |
-
GET /cdn/<path>
|
| 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>
|
| 12 |
-
GET /files
|
| 13 |
-
DELETE /file/<file_id>
|
| 14 |
-
GET /health
|
| 15 |
"""
|
| 16 |
|
| 17 |
import os
|
|
@@ -26,6 +26,7 @@ import mimetypes
|
|
| 26 |
import atexit
|
| 27 |
from datetime import datetime, timezone
|
| 28 |
from pathlib import Path
|
|
|
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
@@ -39,9 +40,9 @@ from db import (
|
|
| 39 |
)
|
| 40 |
from tg import upload_to_telegram, download_from_telegram, init_bot_pool, close_http
|
| 41 |
|
| 42 |
-
#
|
| 43 |
# App
|
| 44 |
-
#
|
| 45 |
app = Flask(__name__)
|
| 46 |
CORS(app)
|
| 47 |
|
|
@@ -54,25 +55,48 @@ FRONTEND_PATH = _HERE / "frontend.html"
|
|
| 54 |
# Allowed characters in a custom path segment
|
| 55 |
_CUSTOM_PATH_RE = re.compile(r'^[a-zA-Z0-9._\-/]+$')
|
| 56 |
|
| 57 |
-
#
|
| 58 |
# Startup / Shutdown
|
| 59 |
-
#
|
| 60 |
-
_initialized
|
|
|
|
|
|
|
| 61 |
|
| 62 |
def _startup():
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if _initialized:
|
| 65 |
return
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
atexit.register(close_http) # drain httpx connection pool on exit
|
| 71 |
|
| 72 |
|
| 73 |
-
#
|
| 74 |
# Helpers
|
| 75 |
-
#
|
| 76 |
def require_api_key():
|
| 77 |
key = request.headers.get("X-API-Key", "")
|
| 78 |
if key != ADMIN_API_KEY:
|
|
@@ -113,9 +137,9 @@ def _make_stream_response(record: dict, disposition: str = "inline") -> Response
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
| 116 |
-
#
|
| 117 |
# Routes
|
| 118 |
-
#
|
| 119 |
|
| 120 |
@app.route("/")
|
| 121 |
def frontend():
|
|
@@ -126,24 +150,48 @@ def frontend():
|
|
| 126 |
|
| 127 |
@app.route("/health")
|
| 128 |
def health():
|
| 129 |
-
_startup()
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return jsonify({
|
| 132 |
-
"status":
|
| 133 |
-
"timestamp":
|
| 134 |
"total_files": total,
|
| 135 |
-
"base_url":
|
| 136 |
})
|
| 137 |
|
| 138 |
|
| 139 |
-
#
|
| 140 |
@app.route("/cdn/<path:path>")
|
| 141 |
def cdn_file(path: str):
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
record = get_file_by_custom_path(path)
|
| 145 |
|
| 146 |
-
# 2
|
| 147 |
if not record:
|
| 148 |
record = get_file_record(path)
|
| 149 |
|
|
@@ -156,10 +204,14 @@ def cdn_file(path: str):
|
|
| 156 |
return _make_stream_response(record)
|
| 157 |
|
| 158 |
|
| 159 |
-
#
|
| 160 |
@app.route("/upload", methods=["POST"])
|
| 161 |
def upload_file_route():
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
require_api_key()
|
| 164 |
|
| 165 |
if "file" not in request.files:
|
|
@@ -208,25 +260,29 @@ def upload_file_route():
|
|
| 208 |
custom_path=clean_custom_path,
|
| 209 |
)
|
| 210 |
|
| 211 |
-
logger.info(f"Uploaded {filename!r}
|
| 212 |
|
| 213 |
return jsonify({
|
| 214 |
-
"file_id":
|
| 215 |
-
"filename":
|
| 216 |
-
"mime_type":
|
| 217 |
-
"size_bytes":
|
| 218 |
-
"custom_path":
|
| 219 |
-
"public_url":
|
| 220 |
"cdn_url_by_id": _build_public_url(file_id),
|
| 221 |
"cdn_url_by_path": _build_public_url(clean_custom_path) if clean_custom_path else None,
|
| 222 |
-
"uploaded_at":
|
| 223 |
})
|
| 224 |
|
| 225 |
|
| 226 |
-
#
|
| 227 |
@app.route("/file/<file_id>", methods=["GET"])
|
| 228 |
def download_file_route(file_id: str):
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
require_api_key()
|
| 231 |
|
| 232 |
record = get_file_record(file_id)
|
|
@@ -249,10 +305,14 @@ def download_file_route(file_id: str):
|
|
| 249 |
)
|
| 250 |
|
| 251 |
|
| 252 |
-
#
|
| 253 |
@app.route("/files")
|
| 254 |
def list_files_route():
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
require_api_key()
|
| 257 |
|
| 258 |
limit = request.args.get("limit", 50, type=int)
|
|
@@ -265,14 +325,18 @@ def list_files_route():
|
|
| 265 |
return jsonify({"total": total, "limit": limit, "offset": offset, "files": records})
|
| 266 |
|
| 267 |
|
| 268 |
-
#
|
| 269 |
@app.route("/file/<file_id>", methods=["DELETE"])
|
| 270 |
def delete_file_route(file_id: str):
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
require_api_key()
|
| 273 |
|
| 274 |
record = get_file_record(file_id)
|
| 275 |
if not record:
|
| 276 |
return jsonify({"detail": "File not found."}), 404
|
| 277 |
delete_file_record(file_id)
|
| 278 |
-
return jsonify({"deleted": True, "file_id": file_id})
|
|
|
|
| 1 |
"""
|
| 2 |
+
TG Storage API \u2014 Store & retrieve files via Telegram as a backend.
|
| 3 |
|
| 4 |
Endpoints:
|
| 5 |
+
GET / \u2014 Frontend UI
|
| 6 |
+
POST /upload \u2014 Upload a file (optional custom_path)
|
| 7 |
+
GET /cdn/<path> \u2014 Public CDN URL \u2014 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> \u2014 Download (auth required, forces attachment)
|
| 12 |
+
GET /files \u2014 List all stored files
|
| 13 |
+
DELETE /file/<file_id> \u2014 Delete a file record
|
| 14 |
+
GET /health \u2014 Health check
|
| 15 |
"""
|
| 16 |
|
| 17 |
import os
|
|
|
|
| 26 |
import atexit
|
| 27 |
from datetime import datetime, timezone
|
| 28 |
from pathlib import Path
|
| 29 |
+
from typing import Optional
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
|
|
| 40 |
)
|
| 41 |
from tg import upload_to_telegram, download_from_telegram, init_bot_pool, close_http
|
| 42 |
|
| 43 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 44 |
# App
|
| 45 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 46 |
app = Flask(__name__)
|
| 47 |
CORS(app)
|
| 48 |
|
|
|
|
| 55 |
# Allowed characters in a custom path segment
|
| 56 |
_CUSTOM_PATH_RE = re.compile(r'^[a-zA-Z0-9._\-/]+$')
|
| 57 |
|
| 58 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 59 |
# Startup / Shutdown
|
| 60 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 61 |
+
_initialized = False
|
| 62 |
+
_startup_error: Optional[str] = None # FIX: track last startup failure message
|
| 63 |
+
|
| 64 |
|
| 65 |
def _startup():
|
| 66 |
+
"""
|
| 67 |
+
FIX: Idempotent startup with proper error tracking.
|
| 68 |
+
|
| 69 |
+
- If already initialized \u2192 return immediately (no-op).
|
| 70 |
+
- If previously failed \u2192 retry (allows recovery when Supabase was
|
| 71 |
+
temporarily unreachable, e.g. cold-start DNS not yet ready).
|
| 72 |
+
- On failure \u2192 reset _initialized=False so next request retries,
|
| 73 |
+
and store the error message for clean logging.
|
| 74 |
+
- Raises RuntimeError so callers can return 503 instead of crashing.
|
| 75 |
+
"""
|
| 76 |
+
global _initialized, _startup_error
|
| 77 |
+
|
| 78 |
if _initialized:
|
| 79 |
return
|
| 80 |
+
|
| 81 |
+
_startup_error = None
|
| 82 |
+
try:
|
| 83 |
+
init_db() # connect Supabase + verify table exists
|
| 84 |
+
init_bot_pool() # verify tokens.txt & build bot pool
|
| 85 |
+
_initialized = True
|
| 86 |
+
logger.info("Startup complete \u2014 Supabase + Telegram bot pool ready.")
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
_initialized = False # allow retry on next request
|
| 89 |
+
_startup_error = str(exc)
|
| 90 |
+
logger.error(f"Startup failed: {exc}")
|
| 91 |
+
raise RuntimeError(f"Service not ready: {exc}") from exc
|
| 92 |
+
|
| 93 |
|
| 94 |
atexit.register(close_http) # drain httpx connection pool on exit
|
| 95 |
|
| 96 |
|
| 97 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 98 |
# Helpers
|
| 99 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 100 |
def require_api_key():
|
| 101 |
key = request.headers.get("X-API-Key", "")
|
| 102 |
if key != ADMIN_API_KEY:
|
|
|
|
| 137 |
)
|
| 138 |
|
| 139 |
|
| 140 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 141 |
# Routes
|
| 142 |
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 143 |
|
| 144 |
@app.route("/")
|
| 145 |
def frontend():
|
|
|
|
| 150 |
|
| 151 |
@app.route("/health")
|
| 152 |
def health():
|
| 153 |
+
# FIX: wrap _startup() so a Supabase/network error returns 503 instead of 500 crash
|
| 154 |
+
try:
|
| 155 |
+
_startup()
|
| 156 |
+
except Exception as exc:
|
| 157 |
+
return jsonify({
|
| 158 |
+
"status": "error",
|
| 159 |
+
"error": str(exc),
|
| 160 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 161 |
+
"base_url": BASE_URL,
|
| 162 |
+
}), 503
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
total = count_files()
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
logger.exception("count_files failed during health check")
|
| 168 |
+
return jsonify({
|
| 169 |
+
"status": "degraded",
|
| 170 |
+
"error": str(exc),
|
| 171 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 172 |
+
"base_url": BASE_URL,
|
| 173 |
+
}), 503
|
| 174 |
+
|
| 175 |
return jsonify({
|
| 176 |
+
"status": "ok",
|
| 177 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 178 |
"total_files": total,
|
| 179 |
+
"base_url": BASE_URL,
|
| 180 |
})
|
| 181 |
|
| 182 |
|
| 183 |
+
# \u2500\u2500 CDN \u2014 public, no auth \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 184 |
@app.route("/cdn/<path:path>")
|
| 185 |
def cdn_file(path: str):
|
| 186 |
+
try:
|
| 187 |
+
_startup()
|
| 188 |
+
except Exception as exc:
|
| 189 |
+
return jsonify({"detail": str(exc)}), 503
|
| 190 |
+
|
| 191 |
+
# 1 \u2014 custom path lookup
|
| 192 |
record = get_file_by_custom_path(path)
|
| 193 |
|
| 194 |
+
# 2 \u2014 fall back to file_id lookup
|
| 195 |
if not record:
|
| 196 |
record = get_file_record(path)
|
| 197 |
|
|
|
|
| 204 |
return _make_stream_response(record)
|
| 205 |
|
| 206 |
|
| 207 |
+
# \u2500\u2500 Upload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 208 |
@app.route("/upload", methods=["POST"])
|
| 209 |
def upload_file_route():
|
| 210 |
+
try:
|
| 211 |
+
_startup()
|
| 212 |
+
except Exception as exc:
|
| 213 |
+
return jsonify({"detail": str(exc)}), 503
|
| 214 |
+
|
| 215 |
require_api_key()
|
| 216 |
|
| 217 |
if "file" not in request.files:
|
|
|
|
| 260 |
custom_path=clean_custom_path,
|
| 261 |
)
|
| 262 |
|
| 263 |
+
logger.info(f"Uploaded {filename!r} \u2192 {public_url}")
|
| 264 |
|
| 265 |
return jsonify({
|
| 266 |
+
"file_id": file_id,
|
| 267 |
+
"filename": filename,
|
| 268 |
+
"mime_type": mime_type,
|
| 269 |
+
"size_bytes": size,
|
| 270 |
+
"custom_path": clean_custom_path,
|
| 271 |
+
"public_url": public_url,
|
| 272 |
"cdn_url_by_id": _build_public_url(file_id),
|
| 273 |
"cdn_url_by_path": _build_public_url(clean_custom_path) if clean_custom_path else None,
|
| 274 |
+
"uploaded_at": datetime.now(timezone.utc).isoformat(),
|
| 275 |
})
|
| 276 |
|
| 277 |
|
| 278 |
+
# \u2500\u2500 Authenticated download \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 279 |
@app.route("/file/<file_id>", methods=["GET"])
|
| 280 |
def download_file_route(file_id: str):
|
| 281 |
+
try:
|
| 282 |
+
_startup()
|
| 283 |
+
except Exception as exc:
|
| 284 |
+
return jsonify({"detail": str(exc)}), 503
|
| 285 |
+
|
| 286 |
require_api_key()
|
| 287 |
|
| 288 |
record = get_file_record(file_id)
|
|
|
|
| 305 |
)
|
| 306 |
|
| 307 |
|
| 308 |
+
# \u2500\u2500 List \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 309 |
@app.route("/files")
|
| 310 |
def list_files_route():
|
| 311 |
+
try:
|
| 312 |
+
_startup()
|
| 313 |
+
except Exception as exc:
|
| 314 |
+
return jsonify({"detail": str(exc)}), 503
|
| 315 |
+
|
| 316 |
require_api_key()
|
| 317 |
|
| 318 |
limit = request.args.get("limit", 50, type=int)
|
|
|
|
| 325 |
return jsonify({"total": total, "limit": limit, "offset": offset, "files": records})
|
| 326 |
|
| 327 |
|
| 328 |
+
# \u2500\u2500 Delete \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
| 329 |
@app.route("/file/<file_id>", methods=["DELETE"])
|
| 330 |
def delete_file_route(file_id: str):
|
| 331 |
+
try:
|
| 332 |
+
_startup()
|
| 333 |
+
except Exception as exc:
|
| 334 |
+
return jsonify({"detail": str(exc)}), 503
|
| 335 |
+
|
| 336 |
require_api_key()
|
| 337 |
|
| 338 |
record = get_file_record(file_id)
|
| 339 |
if not record:
|
| 340 |
return jsonify({"detail": "File not found."}), 404
|
| 341 |
delete_file_record(file_id)
|
| 342 |
+
return jsonify({"deleted": True, "file_id": file_id})
|