File size: 14,565 Bytes
353a253
b88bf76
353a253
 
b88bf76
 
 
353a253
 
 
b88bf76
 
 
 
353a253
 
 
 
 
9c54aa9
 
 
 
353a253
 
a13a754
 
353a253
b88bf76
353a253
 
 
a13a754
 
353a253
 
 
 
 
 
 
 
b88bf76
353a253
b88bf76
a13a754
 
353a253
 
 
 
 
 
 
a13a754
353a253
 
b88bf76
a13a754
b88bf76
 
 
 
a13a754
 
b88bf76
 
 
 
 
 
 
 
 
 
 
 
a13a754
 
b88bf76
 
 
 
 
 
 
 
 
 
 
 
 
a13a754
 
 
353a253
b88bf76
353a253
b88bf76
a13a754
 
 
 
353a253
 
 
 
 
a13a754
353a253
a13a754
353a253
a13a754
353a253
 
 
 
 
 
 
a13a754
 
353a253
a13a754
353a253
 
a13a754
353a253
a13a754
 
 
353a253
a13a754
353a253
 
 
 
 
 
b88bf76
353a253
b88bf76
353a253
a13a754
 
353a253
a13a754
 
353a253
 
a13a754
 
b88bf76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a13a754
b88bf76
 
a13a754
b88bf76
a13a754
353a253
 
b88bf76
a13a754
 
b88bf76
 
 
 
 
 
a13a754
353a253
b88bf76
353a253
a13a754
353a253
 
a13a754
 
 
 
353a253
a13a754
353a253
 
b88bf76
a13a754
 
b88bf76
 
 
 
 
a13a754
 
 
 
 
 
 
353a253
a13a754
353a253
 
 
 
 
 
a13a754
 
 
 
 
353a253
a13a754
 
 
353a253
 
 
a13a754
353a253
 
a13a754
353a253
 
 
 
 
 
a13a754
353a253
 
 
 
 
 
 
 
 
 
b88bf76
353a253
a13a754
b88bf76
 
 
 
 
 
353a253
 
b88bf76
a13a754
353a253
 
b88bf76
a13a754
 
b88bf76
 
 
 
 
a13a754
 
 
353a253
a13a754
353a253
 
a13a754
353a253
 
a13a754
353a253
a13a754
 
 
353a253
 
 
 
 
 
 
b88bf76
a13a754
 
b88bf76
 
 
 
 
a13a754
 
 
 
 
 
 
 
 
 
353a253
 
b88bf76
a13a754
 
b88bf76
 
 
 
 
a13a754
 
 
353a253
a13a754
 
b88bf76
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""
TG Storage API \u2014 Store & retrieve files via Telegram as a backend.

Endpoints:
  GET    /                        \u2014 Frontend UI
  POST   /upload                  \u2014 Upload a file (optional custom_path)
  GET    /cdn/<path>              \u2014 Public CDN URL \u2014 works with:
                                      /cdn/<file_id>
                                      /cdn/<custom_path>        e.g. /cdn/logo.png
                                      /cdn/<folder/name.ext>   e.g. /cdn/images/avatar.jpg
  GET    /file/<file_id>          \u2014 Download (auth required, forces attachment)
  GET    /files                   \u2014 List all stored files
  DELETE /file/<file_id>          \u2014 Delete a file record
  GET    /health                  \u2014 Health check
"""

import os
import re
import uuid
from dotenv import load_dotenv

load_dotenv()  # load .env before importing app so env vars are available

import logging
import mimetypes
import atexit
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

from flask import Flask, request, jsonify, Response, abort, send_file
from flask_cors import CORS

from db import (
    init_db, save_file_record,
    get_file_record, get_file_by_custom_path,
    list_file_records, delete_file_record, count_files,
)
from tg import upload_to_telegram, download_from_telegram, init_bot_pool, close_http

# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
#  App
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
app = Flask(__name__)
CORS(app)

ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "changeme")
BASE_URL      = os.getenv("BASE_URL", "http://localhost:8082").rstrip("/")

_HERE         = Path(__file__).parent
FRONTEND_PATH = _HERE / "frontend.html"

# Allowed characters in a custom path segment
_CUSTOM_PATH_RE = re.compile(r'^[a-zA-Z0-9._\-/]+$')

# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
#  Startup / Shutdown
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
_initialized   = False
_startup_error: Optional[str] = None   # FIX: track last startup failure message


def _startup():
    """
    FIX: Idempotent startup with proper error tracking.

    - If already initialized \u2192 return immediately (no-op).
    - If previously failed \u2192 retry (allows recovery when Supabase was
      temporarily unreachable, e.g. cold-start DNS not yet ready).
    - On failure \u2192 reset _initialized=False so next request retries,
      and store the error message for clean logging.
    - Raises RuntimeError so callers can return 503 instead of crashing.
    """
    global _initialized, _startup_error

    if _initialized:
        return

    _startup_error = None
    try:
        init_db()          # connect Supabase + verify table exists
        init_bot_pool()    # verify tokens.txt & build bot pool
        _initialized = True
        logger.info("Startup complete \u2014 Supabase + Telegram bot pool ready.")
    except Exception as exc:
        _initialized   = False          # allow retry on next request
        _startup_error = str(exc)
        logger.error(f"Startup failed: {exc}")
        raise RuntimeError(f"Service not ready: {exc}") from exc


atexit.register(close_http)  # drain httpx connection pool on exit


# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
#  Helpers
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
def require_api_key():
    key = request.headers.get("X-API-Key", "")
    if key != ADMIN_API_KEY:
        abort(401, description="Invalid or missing API key")


def _sanitize_custom_path(raw: str) -> str:
    path = raw.strip().strip("/")
    if not path:
        abort(400, description="custom_path cannot be empty after stripping slashes.")
    if ".." in path:
        abort(400, description="custom_path must not contain '..'")
    if not _CUSTOM_PATH_RE.match(path):
        abort(400, description="custom_path may only contain letters, digits, hyphens, underscores, dots, and slashes.")
    return path


def _build_public_url(identifier: str) -> str:
    return f"{BASE_URL}/cdn/{identifier}"


def _make_stream_response(record: dict, disposition: str = "inline") -> Response:
    """Download from Telegram and return to client."""
    try:
        data: bytes = download_from_telegram(record["tg_message_id"], record["tg_file_id"])
    except Exception as exc:
        logger.exception("Telegram download error")
        abort(502, description=str(exc))

    return Response(
        data,
        mimetype=record["mime_type"],
        headers={
            "Content-Disposition": f'{disposition}; filename="{record["filename"]}"',
            "Content-Length":      str(len(data)),
            "Cache-Control":       "public, max-age=31536000, immutable",
        },
    )


# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
#  Routes
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500

@app.route("/")
def frontend():
    if FRONTEND_PATH.exists():
        return Response(FRONTEND_PATH.read_text(encoding="utf-8"), mimetype="text/html")
    return Response("<h2>frontend.html not found</h2>", status=404, mimetype="text/html")


@app.route("/health")
def health():
    # FIX: wrap _startup() so a Supabase/network error returns 503 instead of 500 crash
    try:
        _startup()
    except Exception as exc:
        return jsonify({
            "status":    "error",
            "error":     str(exc),
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "base_url":  BASE_URL,
        }), 503

    try:
        total = count_files()
    except Exception as exc:
        logger.exception("count_files failed during health check")
        return jsonify({
            "status":    "degraded",
            "error":     str(exc),
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "base_url":  BASE_URL,
        }), 503

    return jsonify({
        "status":      "ok",
        "timestamp":   datetime.now(timezone.utc).isoformat(),
        "total_files": total,
        "base_url":    BASE_URL,
    })


# \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
@app.route("/cdn/<path:path>")
def cdn_file(path: str):
    try:
        _startup()
    except Exception as exc:
        return jsonify({"detail": str(exc)}), 503

    # 1 \u2014 custom path lookup
    record = get_file_by_custom_path(path)

    # 2 \u2014 fall back to file_id lookup
    if not record:
        record = get_file_record(path)

    if not record:
        return jsonify({
            "detail": f"No file found for path '{path}'. "
                      f"Provide a valid file_id or a custom_path assigned at upload."
        }), 404

    return _make_stream_response(record)


# \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
@app.route("/upload", methods=["POST"])
def upload_file_route():
    try:
        _startup()
    except Exception as exc:
        return jsonify({"detail": str(exc)}), 503

    require_api_key()

    if "file" not in request.files:
        return jsonify({"detail": "No file provided."}), 400

    file = request.files["file"]
    content = file.read()
    if not content:
        return jsonify({"detail": "Empty file."}), 400

    filename  = file.filename or f"upload_{uuid.uuid4().hex}"
    mime_type = file.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
    size      = len(content)

    # Validate + normalise custom_path if provided
    clean_custom_path = None
    custom_path_raw = request.form.get("custom_path", "")
    if custom_path_raw and custom_path_raw.strip():
        clean_custom_path = _sanitize_custom_path(custom_path_raw)
        existing = get_file_by_custom_path(clean_custom_path)
        if existing:
            return jsonify({
                "detail": f"custom_path '{clean_custom_path}' is already taken by file_id={existing['file_id']}."
            }), 409

    # Upload bytes to Telegram
    try:
        tg_message_id, tg_file_id = upload_to_telegram(content, filename, mime_type)
    except Exception as exc:
        logger.exception("Telegram upload error")
        return jsonify({"detail": str(exc)}), 502

    # Build URLs
    file_id    = str(uuid.uuid4())
    cdn_key    = clean_custom_path if clean_custom_path else file_id
    public_url = _build_public_url(cdn_key)

    save_file_record(
        file_id=file_id,
        filename=filename,
        mime_type=mime_type,
        size=size,
        tg_message_id=tg_message_id,
        tg_file_id=tg_file_id,
        public_url=public_url,
        custom_path=clean_custom_path,
    )

    logger.info(f"Uploaded {filename!r} \u2192 {public_url}")

    return jsonify({
        "file_id":         file_id,
        "filename":        filename,
        "mime_type":       mime_type,
        "size_bytes":      size,
        "custom_path":     clean_custom_path,
        "public_url":      public_url,
        "cdn_url_by_id":   _build_public_url(file_id),
        "cdn_url_by_path": _build_public_url(clean_custom_path) if clean_custom_path else None,
        "uploaded_at":     datetime.now(timezone.utc).isoformat(),
    })


# \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
@app.route("/file/<file_id>", methods=["GET"])
def download_file_route(file_id: str):
    try:
        _startup()
    except Exception as exc:
        return jsonify({"detail": str(exc)}), 503

    require_api_key()

    record = get_file_record(file_id)
    if not record:
        return jsonify({"detail": "File not found."}), 404

    try:
        data: bytes = download_from_telegram(record["tg_message_id"], record["tg_file_id"])
    except Exception as exc:
        logger.exception("Download error")
        return jsonify({"detail": str(exc)}), 502

    return Response(
        data,
        mimetype=record["mime_type"],
        headers={
            "Content-Disposition": f'attachment; filename="{record["filename"]}"',
            "Content-Length":      str(len(data)),
        },
    )


# \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
@app.route("/files")
def list_files_route():
    try:
        _startup()
    except Exception as exc:
        return jsonify({"detail": str(exc)}), 503

    require_api_key()

    limit  = request.args.get("limit", 50, type=int)
    offset = request.args.get("offset", 0, type=int)
    limit  = max(1, min(limit, 500))
    offset = max(0, offset)

    records = list_file_records(limit=limit, offset=offset)
    total   = count_files()
    return jsonify({"total": total, "limit": limit, "offset": offset, "files": records})


# \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
@app.route("/file/<file_id>", methods=["DELETE"])
def delete_file_route(file_id: str):
    try:
        _startup()
    except Exception as exc:
        return jsonify({"detail": str(exc)}), 503

    require_api_key()

    record = get_file_record(file_id)
    if not record:
        return jsonify({"detail": "File not found."}), 404
    delete_file_record(file_id)
    return jsonify({"deleted": True, "file_id": file_id})