File size: 11,232 Bytes
e586f48
08aea0e
 
 
 
 
 
 
21e1fef
 
08aea0e
 
b9cb1c2
08aea0e
 
e586f48
08aea0e
e586f48
 
69eaa4c
08aea0e
 
 
 
 
a5c9173
e586f48
56c3b26
 
08aea0e
0242444
e586f48
 
 
 
a5c9173
3cb8d24
08aea0e
 
 
 
 
e586f48
 
 
 
 
08aea0e
e586f48
 
 
 
08aea0e
e586f48
 
 
08aea0e
 
 
 
e586f48
08aea0e
 
e586f48
a9f22dc
 
e586f48
 
 
 
 
 
 
 
 
 
 
 
 
 
69eaa4c
a5c9173
 
 
e586f48
 
 
a5c9173
 
 
e586f48
 
 
a5c9173
e586f48
 
 
 
 
 
8417250
 
 
a5c9173
 
 
 
e586f48
 
0b390f8
e586f48
 
a5c9173
e586f48
 
a5c9173
 
0ba5932
 
 
 
 
 
 
 
 
 
 
 
e586f48
 
a5c9173
 
21e1fef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
afd13cb
 
 
 
 
 
 
 
7badcff
 
 
afd13cb
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
ee0f8bd
e586f48
 
3cb8d24
 
e586f48
 
 
3cb8d24
e586f48
 
3cb8d24
e586f48
 
3cb8d24
e586f48
3cb8d24
 
 
 
08aea0e
 
 
 
 
e586f48
08aea0e
a9f22dc
e586f48
 
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
"""Optional Supabase sign-in and math-class storage."""
from __future__ import annotations

import json
import logging
import os
import re
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
import time
import urllib.error
import urllib.parse
import urllib.request

log = logging.getLogger("mathly.auth")
TIMEOUT = 10
CACHE_TTL = 300
CLASSES_TTL = 60


class AuthUnavailable(RuntimeError):
    pass


class ClassConflict(ValueError):
    pass


class SupabaseAuth:
    def __init__(self, url: str, key: str, teachers: frozenset[str] = frozenset(), secret: str = "",
                 developers: frozenset[str] = frozenset()):
        self.url, self.key, self.secret = url.rstrip("/"), key, secret
        self.teachers, self.developers = teachers, developers
        self._users: dict[str, tuple[float, dict]] = {}
        self._classes: tuple[float, list[dict]] | None = None
        self._access: dict[str, tuple[float, dict | None]] = {}
        self._lock = threading.Lock()

    def user(self, token: str) -> dict | None:
        now = time.monotonic()
        with self._lock:
            cached = self._users.get(token)
        if cached and cached[0] > now:
            return cached[1]
        request = urllib.request.Request(self.url + "/auth/v1/user",
            headers={"Authorization": f"Bearer {token}", "apikey": self.key, "User-Agent": "mathly/1.0"})
        try:
            with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
                data = json.load(response)
        except urllib.error.HTTPError as exc:
            if exc.code in (400, 401, 403):
                return None
            raise AuthUnavailable(f"supabase answered {exc.code}") from exc
        except (urllib.error.URLError, TimeoutError, ValueError) as exc:
            raise AuthUnavailable(str(exc)) from exc
        if not data.get("id"):
            return None
        user = {"id": data["id"], "email": data.get("email", "")}
        with self._lock:
            self._users[token] = (now + CACHE_TTL, user)
        return user

    def _admin(self, path: str, method: str = "GET", body=None, prefer: str = "return=representation"):
        if not self.secret:
            raise AuthUnavailable("SUPABASE_SECRET_KEY is not set")
        request = urllib.request.Request(self.url + path, method=method,
            data=json.dumps(body).encode() if body is not None else None,
            headers={"Authorization": f"Bearer {self.secret}", "apikey": self.secret,
                     "Content-Type": "application/json", "Prefer": prefer, "User-Agent": "mathly/1.0"})
        try:
            with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
                return json.loads(response.read() or b"null")
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode(errors="replace")[:300]
            if exc.code == 409:
                raise ClassConflict(detail) from exc
            raise AuthUnavailable(f"supabase answered {exc.code}: {detail}") from exc
        except (urllib.error.URLError, TimeoutError, ValueError) as exc:
            raise AuthUnavailable(str(exc)) from exc

    def classes(self, fresh: bool = False) -> list[dict]:
        now = time.monotonic()
        with self._lock:
            cached = self._classes
        if cached and cached[0] > now and not fresh:
            return cached[1]
        rows: list[dict] = []
        if self.secret:
            try:
                rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc") or []
            except AuthUnavailable as exc:
                if "answered 404" not in str(exc):
                    raise
                log.warning("the classes table is missing; every account is allowed")
        for row in rows:
            row["emails"] = [str(value).lower() for value in row.get("emails") or []]
            row["teachers"] = [str(value).lower() for value in row.get("teachers") or []]
            row["lessons"] = row.get("lessons") or []
            row["nicknames"] = row.get("nicknames") or {}
            row["exams"] = row.get("exams") or []
            row["exam_note"] = str(row.get("exam_note") or "")
            row["exam_title"] = str(row.get("exam_title") or "")
        with self._lock:
            self._classes = (now + CLASSES_TTL, rows)
        return rows

    def save_class(self, class_id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
                   lessons: list[str], *, teachers: list[str] | None = None, **_ignored) -> None:
        body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons,
                "teachers": list(teachers or [])}
        if class_id is None:
            self._admin("/rest/v1/classes", "POST", body)
        elif not self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "PATCH", body):
            raise KeyError(class_id)
        self._classes = None

    def save_class_exams(self, class_id: int, exams: list[dict]) -> None:
        """The class's exam board (each exam: subject, date, and the units and note it may name)."""
        if not self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "PATCH", {"exams": exams}):
            raise KeyError(class_id)
        self._classes = None

    def save_class_settings(self, class_id: int, fields: dict) -> None:
        """A class's AI settings (app/ai_batches.py): automatic practice checks, and its daily AI budget."""
        if not self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "PATCH", fields):
            raise KeyError(class_id)
        self._classes = None

    def delete_class(self, class_id: int) -> None:
        self._admin(f"/rest/v1/classes?id=eq.{int(class_id)}", "DELETE")
        self._classes = None

    def ping(self, timeout: float = 8) -> dict:
        """How Supabase answers right now, for Monitoring: its sign-in service and its database, each timed in ms.
        A part that fails holds {"ok": False, "error"} and does not hide the other."""
        def timed(path: str, key: str) -> dict:
            request = urllib.request.Request(self.url + path, headers={
                "apikey": key, "Authorization": f"Bearer {key}", "User-Agent": "mathly/1.0"})
            start = time.monotonic()
            try:
                with urllib.request.urlopen(request, timeout=timeout) as response:
                    response.read()
                return {"ok": True, "ms": round((time.monotonic() - start) * 1000)}
            except urllib.error.HTTPError as exc:
                return {"ok": False, "ms": round((time.monotonic() - start) * 1000), "error": f"HTTP {exc.code}"}
            except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
                return {"ok": False, "error": str(exc) or type(exc).__name__}
        with ThreadPoolExecutor(2) as pool:
            auth = pool.submit(timed, "/auth/v1/health", self.key)
            db = pool.submit(timed, "/rest/v1/classes?select=id&limit=1", self.secret) if self.secret else None
            return {"auth": auth.result(),
                    "db": db.result() if db else {"ok": False, "error": "SUPABASE_SECRET_KEY is not set"}}

    def answer_activity(self, since: datetime, limit: int = 10_000) -> dict | None:
        """Open-answer attempts and photo checks since a time: user, time and verdict only, never the answer.
        None when the answer tables are not installed."""
        stamp = urllib.parse.quote(since.isoformat())
        try:
            attempts = self._admin(f"/rest/v1/math_question_attempts?select=user_id,question_id,created_at"
                                   f"&created_at=gte.{stamp}&order=created_at.asc&limit={limit}") or []
        except AuthUnavailable as exc:
            if "answered 404" in str(exc):
                return None
            raise
        try:
            grades = self._admin(f"/rest/v1/math_answer_grades?select=user_id,state,created_at,verdict:result->>verdict"
                                 f"&created_at=gte.{stamp}&order=created_at.asc&limit={limit}") or []
        except AuthUnavailable as exc:
            if "answered 404" not in str(exc):
                raise
            grades = []
        return {"attempts": attempts, "grades": grades}

    def user_accounts(self, emails: list[str]) -> dict[str, dict]:
        """The id and Google name of each email that has signed in at least once (Supabase's admin user list), by email.
        The name is "" when the account has none."""
        wanted, found = {email.lower() for email in emails}, {}
        page = 1
        while wanted - found.keys():
            data = self._admin(f"/auth/v1/admin/users?page={page}&per_page=1000") or {}
            users = data.get("users") if isinstance(data, dict) else data
            for user in users or []:
                email = str(user.get("email") or "").lower()
                if email in wanted and user.get("id"):
                    meta = user.get("user_metadata") if isinstance(user.get("user_metadata"), dict) else {}
                    name = " ".join(str(meta.get("full_name") or meta.get("name") or "").split())
                    found[email] = {"id": user["id"], "name": name}
            if len(users or []) < 1000 or page >= 50:
                break
            page += 1
        return found

    def user_email(self, user_id: str) -> str:
        """The email of one account (Supabase's admin API), "" when it has none."""
        data = self._admin(f"/auth/v1/admin/users/{user_id}") or {}
        return str(data.get("email") or "").lower() if isinstance(data, dict) else ""

    def user_ids(self, emails: list[str]) -> dict[str, str]:
        """The account id of each email that has signed in at least once, by email."""
        return {email: account["id"] for email, account in self.user_accounts(emails).items()}

    def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
        if not self.secret:
            return None
        now = time.monotonic()
        with self._lock:
            cached = self._access.get(user_id)
        if cached and cached[0] > now and not fresh:
            return cached[1]
        try:
            rows = self._admin("/rest/v1/user_access?" + urllib.parse.urlencode(
                {"user_id": f"eq.{user_id}", "select": "user_id,email,roles,updated_at"}))
            value = rows[0] if rows else None
        except AuthUnavailable as exc:
            if "answered 404" not in str(exc):
                raise
            value = None
        with self._lock:
            self._access[user_id] = (now + CLASSES_TTL, value)
        return value


def load_auth() -> SupabaseAuth | None:
    url = os.environ.get("SUPABASE_URL", "").strip()
    key = os.environ.get("SUPABASE_PUBLISHABLE_KEY", "").strip()
    if not url or not key:
        log.info("sign-in is disabled; the math path is public")
        return None
    emails = lambda name: frozenset(re.split(r"[\s,;]+", os.environ.get(name, "").strip().lower())) - {""}
    return SupabaseAuth(url, key, emails("TEACHER_EMAILS"), os.environ.get("SUPABASE_SECRET_KEY", "").strip(),
                        emails("DEVELOPER_EMAILS"))