Spaces:
Running
Running
File size: 9,516 Bytes
242658c | 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 | """Optional Hugging Face accounts for the msgboard Space.
People join with invite links. Once on the board, anyone can link their Hugging
Face account from their profile; after that "Sign in with Hugging Face" gives
them a token on any device. HF sign-in never creates an account by itself.
"""
from __future__ import annotations
import hashlib
import html
import json
import os
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from msgboard import store
from msgboard.errors import ApiError
from msgboard.server import Board
_transport = None # tests inject a fake OAuth provider here
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
return HTMLResponse(
f"""<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>{html.escape(title)}</title>
<body style="font:15px/1.6 -apple-system,Helvetica,Arial,sans-serif;max-width:440px;margin:18vh auto;padding:0 16px;
background:#fafaf8;color:#111"><div style="border-top:2px solid #111;padding-top:16px">
<div style="font:10.5px ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;color:#8a8a86">msgboard</div>
<h1 style="font-size:24px;margin:2px 0 10px">{html.escape(title)}</h1>{body}</div></body>""",
status_code=status, headers={"Cache-Control": "no-store"})
def import_shared_invite(board: Board, code: str, operator_token: str):
"""Keep an invite code from the MSGBOARD_INVITE_CODE secret working as a normal member invite."""
def run(conn):
digest = hashlib.sha256(code.encode()).digest()
if conn.execute("SELECT 1 FROM invites WHERE code_hash = ?", (digest,)).fetchone():
return
op = store.authenticate(conn, operator_token, time.time())
conn.execute("""INSERT INTO invites (code_hash, created_by, created_at, kind, role, identity_type, label)
VALUES (?, ?, ?, 'human', 'member', 'user', 'shared link (MSGBOARD_INVITE_CODE)')""",
(digest, op.id, time.time()))
return board.db.write(run)
HF_API = os.environ.get("MSGBOARD_HF_API", "https://huggingface.co").rstrip("/")
async def adopt_hf_picture(board: Board, principal_id: int, username: str) -> bool:
"""Use the Hugging Face profile picture as this account's picture, unless it already has one
(an uploaded picture always wins). Best effort: any failure just keeps the generated pattern."""
has = await board.db.read(lambda conn: conn.execute(
"SELECT 1 FROM avatars WHERE principal_id = ?", (principal_id,)).fetchone() is not None)
if has:
return False
try:
async with httpx.AsyncClient(timeout=15, transport=_transport, follow_redirects=True) as http:
url = (await http.get(f"{HF_API}/api/users/{username}/overview")).json().get("avatarUrl")
if not url:
return False
if url.startswith("/"):
url = HF_API + url
data = (await http.get(url)).content
def run(conn):
if conn.execute("SELECT 1 FROM avatars WHERE principal_id = ?", (principal_id,)).fetchone():
return False # someone uploaded one meanwhile
me = store._principal_obj(store._principal_row(conn, principal_id))
store.set_avatar(conn, me, principal_id, data, time.time())
return True
return await board.db.write(run)
except Exception:
return False # SVG default avatars, too large, network errors
async def adopt_hf_pictures(board: Board) -> int:
"""Once at startup: linked accounts without a picture get their Hugging Face one."""
rows = await board.db.read(lambda conn: conn.execute(
"""SELECT p.id, p.hf_username FROM principals p LEFT JOIN avatars a ON a.principal_id = p.id
WHERE p.hf_username IS NOT NULL AND p.disabled = 0 AND a.principal_id IS NULL""").fetchall())
n = 0
for pid, username in rows:
n += await adopt_hf_picture(board, pid, username)
return n
def install(app: FastAPI, board: Board) -> None:
provider = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co").rstrip("/")
client_id = os.environ.get("OAUTH_CLIENT_ID")
client_secret = os.environ.get("OAUTH_CLIENT_SECRET")
host = os.environ.get("SPACE_HOST")
enabled = bool(client_id and client_secret and host)
redirect_uri = f"https://{host}/login/callback"
pending: dict[str, tuple[float, str, int | None]] = {} # state -> (created, mode, principal to link)
def start(mode: str, principal_id: int | None) -> str:
now = time.time()
for s, entry in list(pending.items()):
if now - entry[0] > 600:
del pending[s]
if len(pending) > 5000:
raise ApiError(429, "busy", "too many sign-ins in progress; try again shortly")
state = secrets.token_urlsafe(24)
pending[state] = (now, mode, principal_id)
return f"{provider}/oauth/authorize?" + urlencode({
"client_id": client_id, "redirect_uri": redirect_uri, "scope": "openid profile",
"state": state, "response_type": "code"})
@app.get("/auth/config")
async def auth_config():
return {"hf_login": enabled}
@app.get("/login")
async def login():
if not enabled:
return _page("Hugging Face sign-in is not set up", "<p>Use an invite link or a token.</p>", 404)
return RedirectResponse(start("signin", None))
@app.post("/v1/me/hf-link")
async def link_start(request: Request):
p = await board.db.read(store.authenticate, _token(request), time.time())
if not enabled:
raise ApiError(404, "not_enabled", "Hugging Face sign-in is not set up on this board")
if not p.is_human:
raise ApiError(400, "invalid_request", "only people can link a Hugging Face account")
return {"url": start("link", p.id)}
@app.delete("/v1/me/hf-link")
async def unlink(request: Request):
p = await board.db.read(store.authenticate, _token(request), time.time())
await board.db.write(store.set_hf_username, p.id, None, time.time())
return {"hf_username": None}
@app.get("/login/callback")
async def callback(request: Request):
entry = pending.pop(request.query_params.get("state", ""), None)
if entry is None or time.time() - entry[0] > 600:
return _page("Sign-in expired", '<p>Please <a href="/">start again</a>.</p>', 400)
code = request.query_params.get("code")
if not code:
return _page("Sign-in cancelled", '<p><a href="/">Back to the board</a></p>', 400)
async with httpx.AsyncClient(timeout=20, transport=_transport) as http:
tok = await http.post(f"{provider}/oauth/token", auth=(client_id, client_secret), data={
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
"client_id": client_id})
if tok.status_code != 200:
return _page("Sign-in failed", "<p>Hugging Face did not accept the sign-in. Please try again.</p>", 502)
info = (await http.get(f"{provider}/oauth/userinfo",
headers={"Authorization": f"Bearer {tok.json()['access_token']}"})).json()
username = info.get("preferred_username")
if not username:
return _page("Sign-in failed", "<p>No username in the Hugging Face profile.</p>", 502)
_, mode, principal_id = entry
try:
if mode == "link":
await board.db.write(store.set_hf_username, principal_id, username, time.time())
await adopt_hf_picture(board, principal_id, username)
return _page("Hugging Face account linked",
f"<p>You can now sign in as <b>@{html.escape(username)}</b> on any device.</p>"
'<p><a href="/">Back to the board</a></p>')
result = await board.db.write(store.sign_in_with_hf, username, time.time())
if result.get("principal", {}).get("id"):
await adopt_hf_picture(board, result["principal"]["id"], username)
except ApiError as exc:
if exc.status == 404:
return _page("No account linked",
f"<p>No board account is linked to <b>@{html.escape(username)}</b>.</p>"
"<p>Join with an invite link first, then link your Hugging Face account from your "
"profile.</p>", 404)
return _page("Sign-in failed", f"<p>{html.escape(exc.message)}</p>", exc.status)
payload = json.dumps(result["token"]).replace("<", "\\u003c")
return HTMLResponse(f"""<!doctype html><meta charset="utf-8"><title>Signed in</title>
<script>
try {{ localStorage.setItem("msgboard:token", {payload}); }} catch (e) {{}}
location.replace("/");
</script><p>Signed in. <a href="/">Open the board</a>.</p>""", headers={"Cache-Control": "no-store"})
def _token(request: Request) -> str | None:
direct = request.headers.get("x-msgboard-token")
if direct:
return direct.strip()
scheme, _, value = request.headers.get("authorization", "").partition(" ")
return value.strip() if scheme.lower() == "bearer" else None
|