File size: 7,585 Bytes
5969f18
 
 
 
b989503
5969f18
 
 
 
b989503
 
 
 
 
 
 
5969f18
 
 
b989503
5969f18
b989503
5969f18
 
 
 
b989503
5969f18
 
 
 
b989503
5969f18
 
b989503
 
 
 
5969f18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b989503
 
 
 
 
 
 
 
 
 
 
 
 
 
5969f18
 
 
 
 
 
 
 
 
 
 
b989503
 
 
 
 
 
 
 
 
 
5969f18
 
 
 
 
 
 
 
b989503
 
 
 
5969f18
 
 
 
 
 
 
b989503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5969f18
 
b989503
 
5969f18
 
b989503
5969f18
b989503
5969f18
b989503
5969f18
b989503
 
 
5969f18
 
 
b989503
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
"""
gamify_gate.py β€” access gate shared by the three Hugging Face Spaces
(Video Processor, Thumbnail Editor, Upload Center).

admin.gamify.pt/home opens each tool in an IFRAME with  https://<space>.hf.space/?__theme=dark&gate=<token>
The token comes from make_gate_token() on the admin side:

    base64url(JSON {"u": user, "exp": unix_time, "src": "home"}) + "." + base64url(HMAC-SHA256(secret, payload))

Every request is checked on the server (signature + "exp"). The token is accepted from:
  1. the URL (?gate=...)                        β€” the first page load
  2. the page address the browser sends along   β€” Gradio's own requests from that page (Referer)
  3. a cookie set on the first page load        β€” SameSite=None; Secure; Partitioned, so it also
                                                  works inside the admin.gamify.pt iframe
Files that are the same for everyone (Gradio's JS/CSS/fonts) are served without a token.
When the token expires, the tool shows "open it again from admin.gamify.pt" and API calls get 401.

Environment (Space settings β†’ Variables and secrets)
    GAMIFY_GATE_SECRET   (Secret)   same value as on admin.gamify.pt β€” required
    GAMIFY_LOGIN_URL     (Variable) default https://admin.gamify.pt/home

Usage:  from gamify_gate import GamifyGate;  app.add_middleware(GamifyGate)
"""
import base64
import hashlib
import hmac
import html
import json
import logging
import os
import time
from urllib.parse import parse_qsl, urlsplit

COOKIE_NAME = "gamify_gate"
TOKEN_PARAMS = ("gate", "token")
REQUIRED_SRC = "home"
OPEN_PATHS = ("/healthz", "/favicon.ico", "/manifest.json")
OPEN_PREFIXES = ("/assets/", "/static/", "/gradio_api/static/")     # Gradio's own JS/CSS/fonts (public anyway)


def _b64e(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def _b64d(text: str) -> bytes:
    return base64.urlsafe_b64decode((text + "=" * (-len(text) % 4)).encode())


def verify_token(token, secret, now=None):
    """Returns the token's data {"u", "exp", "src"} if it is genuine and not expired, else None."""
    if not token or not secret or token.count(".") != 1:
        return None
    payload, sig = token.split(".")
    expected = _b64e(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
    if not hmac.compare_digest(expected, sig):
        return None
    try:
        data = json.loads(_b64d(payload))
    except (ValueError, TypeError):
        return None
    exp = data.get("exp") if isinstance(data, dict) else None
    if not isinstance(exp, (int, float)) or exp <= (now if now is not None else time.time()):
        return None
    if REQUIRED_SRC and data.get("src") != REQUIRED_SRC:
        return None
    return data


def _token_from_query(query_string):
    for k, v in parse_qsl(query_string or "", keep_blank_values=True):
        if k in TOKEN_PARAMS:
            return v
    return None


def _header(scope, name):
    for k, v in scope.get("headers") or []:
        if k.lower() == name:
            return v.decode("latin-1")
    return ""


def _cookie_value(scope, name=COOKIE_NAME):
    for key, value in scope.get("headers") or []:
        if key.lower() != b"cookie":
            continue
        for part in value.decode("latin-1").split(";"):
            k, _, v = part.strip().partition("=")
            if k == name:
                return v
    return None


EXPIRED_PAGE = """<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Session expired</title></head>
<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#0D0E12;
color:#ECEDF2;font-family:system-ui,sans-serif;text-align:center;padding:20px">
<div><div style="font-size:18px;font-weight:700;margin-bottom:8px">Session expired</div>
<div style="color:#8C8FA0;margin-bottom:16px">Open this tool again from the GAMIFY admin panel.</div>
<a href="{url}" target="_top" style="background:linear-gradient(90deg,#1ED6E0,#5C7CFF);color:#06131A;
font-weight:800;padding:10px 18px;border-radius:10px;text-decoration:none">Go to admin.gamify.pt</a></div></body></html>"""


class GamifyGate:
    """Pure ASGI middleware (doesn't buffer uploads or streams)."""

    def __init__(self, app, secret=None, login_url=None):
        self.app = app
        self.secret = (secret if secret is not None else os.environ.get("GAMIFY_GATE_SECRET", "")).strip()
        self.login_url = login_url or os.environ.get("GAMIFY_LOGIN_URL", "https://admin.gamify.pt/home")
        if not self.secret:
            logging.error("GAMIFY GATE: GAMIFY_GATE_SECRET is not set β€” nobody can open this tool")

    def _valid(self, token):
        return verify_token(token, self.secret)

    async def _respond(self, send, status, body=b"", headers=()):
        await send({"type": "http.response.start", "status": status, "headers": list(headers)})
        await send({"type": "http.response.body", "body": body})

    async def __call__(self, scope, receive, send):
        kind = scope.get("type")
        path = scope.get("path", "")
        if kind not in ("http", "websocket") or path in OPEN_PATHS or path.startswith(OPEN_PREFIXES):
            return await self.app(scope, receive, send)

        # 1) token in this request's URL (first load of the tool inside the iframe)
        url_token = _token_from_query(scope.get("query_string", b"").decode("latin-1"))
        data = self._valid(url_token)
        if data and kind == "http":
            max_age = max(int(data["exp"] - time.time()), 0)
            cookie = (f"{COOKIE_NAME}={url_token}; Max-Age={max_age}; Path=/; HttpOnly; "
                      f"SameSite=None; Secure; Partitioned").encode()

            async def send_with_cookie(message):
                if message.get("type") == "http.response.start":
                    message = dict(message)
                    message["headers"] = list(message.get("headers") or []) + [(b"set-cookie", cookie)]
                await send(message)

            logging.info(f"GAMIFY GATE: access for {data.get('u')!r} until "
                         f"{time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(data['exp']))}")
            return await self.app(scope, receive, send_with_cookie)
        if data:
            return await self.app(scope, receive, send)

        # 2) Gradio's own requests from that page carry the page address (with ?gate=...)
        referer = _header(scope, b"referer")
        if referer and self._valid(_token_from_query(urlsplit(referer).query)):
            return await self.app(scope, receive, send)

        # 3) the cookie set on the first load
        if self._valid(_cookie_value(scope)):
            return await self.app(scope, receive, send)

        # no valid token: never logged in, tampered, or expired
        if kind == "websocket":
            await receive()
            return await send({"type": "websocket.close", "code": 1008})
        wants_page = scope.get("method") == "GET" and "text/html" in _header(scope, b"accept")
        if wants_page:
            body = EXPIRED_PAGE.replace("{url}", html.escape(self.login_url, quote=True)).encode()
            return await self._respond(send, 401, body, [(b"content-type", b"text/html; charset=utf-8"),
                                                          (b"cache-control", b"no-store")])
        return await self._respond(
            send, 401,
            b'{"error": "Session expired or missing - open this tool again from admin.gamify.pt"}',
            [(b"content-type", b"application/json")])