NitinBot001 commited on
Commit
b88bf76
Β·
verified Β·
1 Parent(s): 233e17d

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +111 -47
main.py CHANGED
@@ -1,17 +1,17 @@
1
  """
2
- TG Storage API β€” Store & retrieve files via Telegram as a backend.
3
 
4
  Endpoints:
5
- GET / β€” Frontend UI
6
- POST /upload β€” Upload a file (optional custom_path)
7
- GET /cdn/<path> β€” Public CDN URL β€” works with:
8
  /cdn/<file_id>
9
  /cdn/<custom_path> e.g. /cdn/logo.png
10
  /cdn/<folder/name.ext> e.g. /cdn/images/avatar.jpg
11
- GET /file/<file_id> β€” Download (auth required, forces attachment)
12
- GET /files β€” List all stored files
13
- DELETE /file/<file_id> β€” Delete a file record
14
- GET /health β€” Health check
15
  """
16
 
17
  import os
@@ -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 = False
 
 
61
 
62
  def _startup():
63
- global _initialized
 
 
 
 
 
 
 
 
 
 
 
64
  if _initialized:
65
  return
66
- init_db() # connect Supabase + verify table exists
67
- init_bot_pool() # verify tokens.txt & build bot pool
68
- _initialized = True
 
 
 
 
 
 
 
 
 
 
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
- total = count_files()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  return jsonify({
132
- "status": "ok",
133
- "timestamp": datetime.now(timezone.utc).isoformat(),
134
  "total_files": total,
135
- "base_url": BASE_URL,
136
  })
137
 
138
 
139
- # ── CDN β€” public, no auth ─────────────────────────────────────────────
140
  @app.route("/cdn/<path:path>")
141
  def cdn_file(path: str):
142
- _startup()
143
- # 1 β€” custom path lookup
 
 
 
 
144
  record = get_file_by_custom_path(path)
145
 
146
- # 2 β€” fall back to file_id lookup
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
- # ── Upload ────────────────────────────────────────────────────────────
160
  @app.route("/upload", methods=["POST"])
161
  def upload_file_route():
162
- _startup()
 
 
 
 
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} β†’ {public_url}")
212
 
213
  return jsonify({
214
- "file_id": file_id,
215
- "filename": filename,
216
- "mime_type": mime_type,
217
- "size_bytes": size,
218
- "custom_path": clean_custom_path,
219
- "public_url": 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": datetime.now(timezone.utc).isoformat(),
223
  })
224
 
225
 
226
- # ── Authenticated download ────────────────────────────────────────────
227
  @app.route("/file/<file_id>", methods=["GET"])
228
  def download_file_route(file_id: str):
229
- _startup()
 
 
 
 
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
- # ── List ──────────────────────────────────────────────────────────────
253
  @app.route("/files")
254
  def list_files_route():
255
- _startup()
 
 
 
 
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
- # ── Delete ────────────────────────────────────────────────────────────
269
  @app.route("/file/<file_id>", methods=["DELETE"])
270
  def delete_file_route(file_id: str):
271
- _startup()
 
 
 
 
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})