File size: 10,312 Bytes
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4fe458
971fa87
 
 
f4fe458
 
 
 
971fa87
f4fe458
 
 
 
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4fe458
 
 
 
 
 
 
 
971fa87
f4fe458
 
 
 
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Input validators for hosting-panel form fields.

Centralises format checks so every route that accepts a token / slug /
secret gets the same defence. Each validator returns the sanitised
value on success or raises HTTPException on failure β€” callers in
routes.py chain these via `Form(...) = Depends(...)` patterns or by
calling them directly.

Defence notes:
- All lengths are bounded so a multi-MB `slug` cannot DoS the DB layer
  or push huge strings into HF Space names (HF rejects names > 32 chars
  but we cap earlier at 64 to be safe).
- We don't allow path separators, whitespace, or `..` in slugs β€”
  HuggingFace space names share a single DNS-like namespace and a
  malicious slug could collide with another user.
- Tokens are length-checked against their format. A bot_token that's
  4 chars is a junk submission; 5000 chars is a DoS attempt.
"""

from __future__ import annotations

import re

from fastapi import HTTPException, status


# ---- Format patterns -----------------------------------------------------

_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,62}[a-z0-9]$")
_HF_SPACE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{1,62}[a-zA-Z0-9]$")
_HF_DATASET_RE = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
_BOT_TOKEN_LEN = (35, 60)         # Telegram bot tokens: 35 chars + "bot" prefix allowed variants
_HF_TOKEN_LEN = (30, 60)          # hf_xxxxx... typically 37 chars
_PROXY_TOKEN_LEN = (16, 128)      # random shared secret; allow wide range
_NVIDIA_KEY_LEN = (20, 200)       # nvapi-... typically ~60 chars
_MODEL_RE = re.compile(r"^[A-Za-z0-9_./-]+$")


# ---- Public validators ---------------------------------------------------

def validate_slug(raw: str) -> str:
    """Strict lowercase slug β€” used for `BotInstance.slug` and HF space names.

    Rejects empty, leading/trailing hyphens, double hyphens, uppercase,
    path separators, and anything that doesn't match `[a-z0-9][a-z0-9_-]+[a-z0-9]`.
    """
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="slug is required")
    slug = raw.strip().lower()
    if not _SLUG_RE.match(slug):
        raise HTTPException(
            status_code=400,
            detail=(
                "slug must be 3-64 chars, lowercase, start/end with alnum, "
                "and contain only a-z, 0-9, '_' or '-'"
            ),
        )
    return slug


def validate_hf_space_name(raw: str) -> str:
    """HF space name β€” slightly looser (allows uppercase) but still strict."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="hf_space_name is required")
    name = raw.strip()
    if not _HF_SPACE_RE.match(name):
        raise HTTPException(
            status_code=400,
            detail="hf_space_name must be 3-64 alphanumeric/underscore/hyphen chars",
        )
    return name


def validate_hf_dataset_repo(raw: str) -> str:
    """HF dataset repo: must be `owner/name`."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="hf_dataset_repo is required")
    repo = raw.strip()
    if not _HF_DATASET_RE.match(repo) or repo.count("/") != 1:
        raise HTTPException(
            status_code=400,
            detail="hf_dataset_repo must be in 'owner/name' format",
        )
    return repo


def validate_bot_token(raw: str) -> str:
    """Telegram bot token. Format: <bot_id>:<44-char base64-ish secret>."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="bot_token is required")
    tok = raw.strip()
    lo, hi = _BOT_TOKEN_LEN
    if not (lo <= len(tok) <= hi):
        raise HTTPException(
            status_code=400,
            detail=f"bot_token length must be {lo}-{hi} chars",
        )
    if ":" not in tok:
        raise HTTPException(
            status_code=400,
            detail="bot_token must be in '<bot_id>:<secret>' format",
        )
    # Defensive: reject whitespace and control chars inside the token.
    if any(c.isspace() or ord(c) < 32 for c in tok):
        raise HTTPException(status_code=400, detail="bot_token contains invalid chars")
    return tok


def validate_hf_token(raw: str) -> str:
    """HuggingFace user access token (`hf_...`)."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="hf_token is required")
    tok = raw.strip()
    lo, hi = _HF_TOKEN_LEN
    if not (lo <= len(tok) <= hi):
        raise HTTPException(
            status_code=400,
            detail=f"hf_token length must be {lo}-{hi} chars",
        )
    if any(c.isspace() or ord(c) < 32 for c in tok):
        raise HTTPException(status_code=400, detail="hf_token contains invalid chars")
    return tok


def validate_proxy_token(raw: str) -> str:
    """Shared RENDER/LLM proxy secret."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="llm_proxy_token is required")
    tok = raw.strip()
    lo, hi = _PROXY_TOKEN_LEN
    if not (lo <= len(tok) <= hi):
        raise HTTPException(
            status_code=400,
            detail=f"llm_proxy_token length must be {lo}-{hi} chars",
        )
    if any(c.isspace() or ord(c) < 32 for c in tok):
        raise HTTPException(status_code=400, detail="llm_proxy_token contains invalid chars")
    return tok


def validate_nvidia_api_key(raw: str) -> str:
    """NVIDIA API key (currently `nvapi-...`)."""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="nvidia_api_key is required")
    key = raw.strip()
    lo, hi = _NVIDIA_KEY_LEN
    if not (lo <= len(key) <= hi):
        raise HTTPException(
            status_code=400,
            detail=f"nvidia_api_key length must be {lo}-{hi} chars",
        )
    if any(c.isspace() or ord(c) < 32 for c in key):
        raise HTTPException(status_code=400, detail="nvidia_api_key contains invalid chars")
    return key


def validate_optional_nvidia_model(raw: str) -> str:
    """NVIDIA model id β€” e.g. `minimaxai/minimax-m3`."""
    if not raw:
        return "minimaxai/minimax-m3"
    model = raw.strip()
    if not _MODEL_RE.match(model):
        raise HTTPException(
            status_code=400,
            detail="nvidia_model must contain only letters, digits, '_', '-', '.', '/'",
        )
    return model


def validate_optional_url(raw: str, field: str) -> str:
    """Optional URL field β€” empty allowed; otherwise basic sanity."""
    if not raw:
        return ""
    url = raw.strip()
    if not (url.startswith("http://") or url.startswith("https://")):
        raise HTTPException(status_code=400, detail=f"{field} must start with http:// or https://")
    if len(url) > 2048:
        raise HTTPException(status_code=400, detail=f"{field} too long (max 2048 chars)")
    return url


def validate_source_repo(raw: str, source_type: str = "hf") -> str:
    """Bot's source code location.

    ``hf``     β†’ ``owner/name`` (HF Hub repo)
    ``github`` β†’ ``https://github.com/owner/repo[.git]`` or any cloneable URL
    ``local``  β†’ optional marker; the panel keeps the zip on disk instead.

    ``local`` accepts an empty string (no external repo).  ``hf`` and
    ``github`` still require a non-empty value.
    """
    if source_type == "local":
        # Local bots get their source from an uploaded zip, not an
        # external repo.  An empty string is fine β€” the panel will
        # store the zip path on the BotInstance row instead.
        return ""
    if not raw or not isinstance(raw, str):
        raise HTTPException(status_code=400, detail="source_repo is required")
    repo = raw.strip()
    if source_type == "github":
        if not (repo.startswith("http://") or repo.startswith("https://") or repo.startswith("git@")):
            raise HTTPException(
                status_code=400,
                detail="source_repo must be a git URL (https://… or git@…) when source_type=github",
            )
        if len(repo) > 256:
            raise HTTPException(status_code=400, detail="source_repo too long (max 256 chars)")
        return repo
    # Default: HF Hub ``owner/name``.
    if "/" not in repo or repo.count("/") > 1:
        raise HTTPException(
            status_code=400,
            detail="source_repo must be in 'owner/name' format for HF Hub",
        )
    if not _HF_DATASET_RE.match(repo):
        raise HTTPException(
            status_code=400,
            detail="source_repo must match 'owner/name' (alphanumeric, '-', '_', '.')",
        )
    if len(repo) > 256:
        raise HTTPException(status_code=400, detail="source_repo too long (max 256 chars)")
    return repo


def validate_source_type(raw: str) -> str:
    """``hf``, ``github`` or ``local``.

    ``local`` means the bot's source lives as a zip uploaded through
    the create form (or replaced later via the Files tab).  Deploys
    from a ``local`` source aren't wired up to DeployOrchestrator yet
    β€” the bot stays in ``pending`` until the user wires source on the
    bot detail page.
    """
    val = (raw or "hf").strip().lower()
    if val not in ("hf", "github", "local"):
        raise HTTPException(
            status_code=400,
            detail="source_type must be 'hf', 'github' or 'local'",
        )
    return val


def validate_framework(raw: str) -> str:
    """Free-form framework hint (e.g. ``docker``, ``python``, ``node``)."""
    if not raw:
        return "docker"
    val = raw.strip().lower()
    if len(val) > 64:
        raise HTTPException(status_code=400, detail="framework too long (max 64 chars)")
    if not re.match(r"^[a-z0-9_./-]+$", val):
        raise HTTPException(
            status_code=400,
            detail="framework must be lowercase alphanumerics with _-./",
        )
    return val


def validate_branch(raw: str) -> str:
    """Git branch name. Conservative whitelist."""
    if not raw:
        return "main"
    val = raw.strip()
    if len(val) > 64:
        raise HTTPException(status_code=400, detail="branch name too long (max 64 chars)")
    if not re.match(r"^[A-Za-z0-9_./-]+$", val):
        raise HTTPException(
            status_code=400,
            detail="branch may only contain alphanumerics, '.', '_', '-', '/'",
        )
    return val