"""
ls_proxy_hf.py โ Reverse proxy for Label Studio on HuggingFace Spaces.
Runs on port 7860 (HF exposed port), forwards to Label Studio on port 8080.
Features kept from local version:
- Custom /projects landing page with flag emojis
- Annotation privacy: non-admins only see their own annotations
- Auto-advance to next task after Submit
- Per-user progress bar at top
- goNextTask via label stream (not admin-only DM actions)
"""
import json
import re as _re
import sqlite3
import os
import smtplib
import threading
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import Flask, request, Response, make_response
app = Flask(__name__, static_folder=None)
LS_URL = "http://localhost:8080"
def _find_db_path():
"""Find the LS SQLite DB with the most users (handles different LS data dir configs)."""
import glob as _g, sqlite3 as _s3
candidates = sorted(_g.glob("/data/**/*.sqlite3", recursive=True) +
_g.glob("/label-studio/**/*.sqlite3", recursive=True))
best, best_n = "/data/ls/label_studio.sqlite3", -1
for p in candidates:
try:
c = _s3.connect(p)
n = c.execute("SELECT COUNT(*) FROM htx_user").fetchone()[0]
c.close()
if n > best_n:
best, best_n = p, n
except Exception:
pass
return best
DB_PATH = _find_db_path()
HF_SPACE_URL = "https://trustllmeu-saga-annotation.hf.space"
def _backup_db():
"""Trigger an immediate DB backup after an annotation is submitted."""
try:
import subprocess
subprocess.run(
["python3", "/app/db_sync.py", "backup"],
timeout=60, capture_output=True,
)
except Exception as e:
print(f"[db_sync] Immediate backup failed: {e}", flush=True)
# Flag emoji prefix โ (ISO 2-letter code for flagcdn.com, language label)
# Keyed by the flag emoji that LS project titles begin with.
# This is resilient to LS re-assigning project IDs on DB resets.
_EMOJI_FLAGS = {
"\U0001F1EE\U0001F1F8": ("is", "รslenska"), # ๐ฎ๐ธ IS
"\U0001F1E9\U0001F1F0": ("dk", "Dansk"), # ๐ฉ๐ฐ DA
"\U0001F1F3\U0001F1F4": ("no", "Norsk"), # ๐ณ๐ด NB
"\U0001F1F8\U0001F1EA": ("se", "Svenska"), # ๐ธ๐ช SV
"\U0001F1E9\U0001F1EA": ("de", "Deutsch"), # ๐ฉ๐ช DE
"\U0001F1EB\U0001F1EE": ("fi", "Suomi"), # ๐ซ๐ฎ FI
}
def _flag_for_title(title):
"""Return (country_code, lang_label) from the leading flag emoji in a project title."""
for emoji, info in _EMOJI_FLAGS.items():
if title.startswith(emoji):
return info
return ("", "")
def _load_manifest_ids():
"""Return set of allowed project ID strings from projects/manifest.json."""
# Try reading from file
for candidate in [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "projects", "manifest.json"),
"/app/projects/manifest.json",
]:
try:
if os.path.exists(candidate):
ids = {str(e["id"]) for e in json.load(open(candidate))}
print(f"[manifest] Loaded {len(ids)} IDs from {candidate}: {sorted(ids)}", flush=True)
return ids
except Exception as e:
print(f"[manifest] Failed to load {candidate}: {e}", flush=True)
# Hardcoded fallback: the 8 canonical projects always have DB IDs 1-8 on HF Space
fallback = {"1", "2", "3", "4", "5", "6", "7", "8"}
print(f"[manifest] Using hardcoded fallback: {sorted(fallback)}", flush=True)
return fallback
_MANIFEST_IDS = _load_manifest_ids()
def _load_manifest_tasks():
"""Return {project_id_str: task_count} from manifest.json."""
for candidate in [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "projects", "manifest.json"),
"/app/projects/manifest.json",
]:
try:
if os.path.exists(candidate):
return {str(e["id"]): int(e.get("tasks", 0)) for e in json.load(open(candidate))}
except Exception:
pass
return {}
_MANIFEST_TASKS = _load_manifest_tasks()
# ---------------------------------------------------------------------------
# Annotation-submission email notifications
# ---------------------------------------------------------------------------
_NOTIFY_FROM = "hoda.fakharzade@gmail.com"
_NOTIFY_TO = "hodfa71@liu.se"
# App password: HF Space secret GMAIL_APP_PASS, fallback to file in /data
def _gmail_app_pass():
v = os.environ.get("GMAIL_APP_PASS", "").strip()
if v:
return v
try:
return open("/data/ls/gmail_app_pass").read().strip()
except Exception:
return ""
def _send_completion_email(project_title: str, annotator_email: str, done: int, total: int):
"""Send email only when a rater finishes ALL tasks in a project."""
app_pass = _gmail_app_pass()
if not app_pass:
print("[webhook] No Gmail app password โ skipping completion email", flush=True)
return
subject = f"[SAGA] โ {annotator_email} completed โ {project_title}"
body = (
f"Annotator : {annotator_email}\n"
f"Project : {project_title}\n"
f"Status : COMPLETED ({done}/{total} tasks)\n\n"
f"View at: {HF_SPACE_URL}\n"
)
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = _NOTIFY_FROM
msg["To"] = _NOTIFY_TO
msg.attach(MIMEText(body, "plain"))
try:
with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as s:
s.ehlo(); s.starttls(); s.ehlo()
s.login(_NOTIFY_FROM, app_pass)
s.sendmail(_NOTIFY_FROM, [_NOTIFY_TO], msg.as_string())
print(f"[webhook] Completion email โ {_NOTIFY_TO} ({annotator_email} finished {project_title})", flush=True)
except Exception as exc:
print(f"[webhook] Email send failed: {exc}", flush=True)
FAVICON_TAG = ''
PROLIFIC_CC = {
"is": os.environ.get("PROLIFIC_CC_IS", "SAGAIS77"),
"da": os.environ.get("PROLIFIC_CC_DA", "SAGADA42"),
"nb": os.environ.get("PROLIFIC_CC_NB", "SAGANB15"),
"sv": os.environ.get("PROLIFIC_CC_SV", "SAGASV33"),
}
PROLIFIC_LANG_PROJECTS = {
"is": [("2", "Pairwise Comparison")],
"da": [("4", "Pairwise Comparison")],
"nb": [("5", "Likert Rating"), ("6", "Pairwise Comparison")],
"sv": [("8", "Pairwise Comparison")],
}
# Per-project metadata for single-project Prolific studies (?project=N)
PROLIFIC_PROJECT_META = {
"1": {"flag": "๐ฎ๐ธ", "lang": "Icelandic / รslenska", "task": "Likert Rating", "cc": os.environ.get("PROLIFIC_CC_P1", "ISLIK01"), "est_min": 90},
"2": {"flag": "๐ฎ๐ธ", "lang": "Icelandic / รslenska", "task": "Pairwise Comparison", "cc": os.environ.get("PROLIFIC_CC_P2", "ISPAIR02"), "est_min": 45},
"3": {"flag": "๐ฉ๐ฐ", "lang": "Danish / Dansk", "task": "Likert Rating", "cc": os.environ.get("PROLIFIC_CC_P3", "DALIK03"), "est_min": 60},
"4": {"flag": "๐ฉ๐ฐ", "lang": "Danish / Dansk", "task": "Pairwise Comparison", "cc": os.environ.get("PROLIFIC_CC_P4", "DAPAIR04"), "est_min": 30},
"5": {"flag": "๐ณ๐ด", "lang": "Norwegian / Norsk", "task": "Likert Rating", "cc": os.environ.get("PROLIFIC_CC_P5", "NBLIK05"), "est_min": 33},
"6": {"flag": "๐ณ๐ด", "lang": "Norwegian / Norsk", "task": "Pairwise Comparison", "cc": os.environ.get("PROLIFIC_CC_P6", "NBPAIR06"), "est_min": 25},
"7": {"flag": "๐ธ๐ช", "lang": "Swedish / Svenska", "task": "Likert Rating", "cc": os.environ.get("PROLIFIC_CC_P7", "SVLIK07"), "est_min": 60},
"8": {"flag": "๐ธ๐ช", "lang": "Swedish / Svenska", "task": "Pairwise Comparison", "cc": os.environ.get("PROLIFIC_CC_P8", "SVPAIR08"), "est_min": 45},
}
PRIVACY_SCRIPT = """
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fwd_headers(req, extra_exclude=()):
# Strip cookie (passed separately via cookies= param) and proxy headers
# so LS sees a plain HTTP request and uses simple token-match CSRF rules.
skip = {"host", "content-length", "transfer-encoding",
"cookie", "x-forwarded-proto", "x-forwarded-for",
"x-real-ip", "x-forwarded-host"} | set(extra_exclude)
h = {k: v for k, v in req.headers if k.lower() not in skip}
h["Host"] = "localhost:8080"
if "Referer" in h:
h["Referer"] = h["Referer"].replace(req.host_url.rstrip("/"), LS_URL)
# Rewrite Origin to LS URL so Django CSRF doesn't reject cross-domain requests
# (saga-research.com is not in CSRF_TRUSTED_ORIGINS; rewriting to localhost passes validation)
if "Origin" in h:
h["Origin"] = LS_URL
# X-LS-Token: caller passes LS API token; we swap it in as Authorization for LS.
# Also strip any HF Bearer token (it's only for HF proxy auth, LS doesn't understand it).
ls_token = req.headers.get("X-LS-Token") or req.args.get("ls_token")
if ls_token:
h["Authorization"] = f"Token {ls_token}"
elif h.get("Authorization", "").startswith("Bearer "):
del h["Authorization"] # HF-only header; LS uses sessionid cookie instead
return h
def _out_headers(resp, extra_exclude=()):
skip = {"transfer-encoding"} | {s.lower() for s in extra_exclude}
try:
items = list(resp.raw.headers.items())
except AttributeError:
items = list(resp.headers.items())
return [(k, v) for k, v in items if k.lower() not in skip]
_user_cache = {}
_USER_CACHE_TTL = 1800 # 30 min โ reduces /api/current-user/whoami spam in LS logs
def get_current_user(req):
import time as _time
session_key = req.cookies.get("sessionid") or req.cookies.get("csrftoken", "")
# No session cookie at all โ definitely not logged in; skip the LS call entirely
# (avoids the 401 /api/current-user/whoami spam in LS logs from unauthenticated browsers)
if not req.cookies.get("sessionid"):
return None, False
now = _time.time()
if session_key and session_key in _user_cache:
uid, is_admin, exp = _user_cache[session_key]
if now < exp:
return uid, is_admin
try:
r = requests.get(
f"{LS_URL}/api/current-user/whoami",
headers=_fwd_headers(req),
cookies=_ls_cookies(req),
timeout=5,
allow_redirects=False,
)
if r.status_code == 200:
uid = r.json().get("id")
if uid:
conn = sqlite3.connect(DB_PATH)
row = conn.execute(
"SELECT is_staff, is_superuser FROM htx_user WHERE id=?", (uid,)
).fetchone()
conn.close()
is_admin = bool(row and (row[0] or row[1]))
if session_key:
_user_cache[session_key] = (uid, is_admin, now + _USER_CACHE_TTL)
return uid, is_admin
except Exception:
pass
return None, False
_STARTING_PAGE = """
SAGA โ Startingโฆ
โณ Label Studio is startingโฆ
This page refreshes automatically every 5 seconds.
"""
def _ls_unavailable():
return Response(_STARTING_PAGE, status=503,
content_type="text/html; charset=utf-8",
headers={"Retry-After": "5"})
def _ls_cookies(req):
"""Only pass LS-relevant cookies to the backend; strip HF-only cookies like 'token'."""
return {k: v for k, v in req.cookies.items() if k not in ("token",)}
def _rewrite_hf_urls(html):
"""Replace absolute HF Space URLs with relative paths.
LS is configured with LABEL_STUDIO_HOST=hf.space URL and injects it into
APP_SETTINGS.hostname and all static asset hrefs. When accessed via
saga-research.com the browser would load scripts/CSS from hf.space and
the React app would POST API calls back to hf.space โ bypassing our proxy
and sending no cookies (different domain) โ 401. Stripping the host
makes every URL relative, so the browser uses whichever domain it's on.
"""
return html.replace(HF_SPACE_URL, "")
def _proxy_stream(path):
url = f"{LS_URL}{path}"
if request.query_string:
url += "?" + request.query_string.decode()
try:
resp = requests.request(
method=request.method,
url=url,
headers=_fwd_headers(request),
data=request.get_data(),
cookies=_ls_cookies(request),
allow_redirects=False,
stream=True,
timeout=30,
)
except requests.exceptions.ConnectionError:
return _ls_unavailable()
except requests.exceptions.Timeout:
return Response("Request timed out โ LS may be overloaded.", status=504,
content_type="text/plain")
return Response(
resp.iter_content(chunk_size=4096),
status=resp.status_code,
headers=_out_headers(resp),
content_type=resp.headers.get("Content-Type", ""),
)
def _proxy_buffered(path):
url = f"{LS_URL}{path}"
if request.query_string:
url += "?" + request.query_string.decode()
return requests.request(
method=request.method,
url=url,
headers=_fwd_headers(request),
data=request.get_data(),
cookies=_ls_cookies(request),
allow_redirects=False,
timeout=30,
)
def _respond_buffered(resp, extra_exclude=()):
skip = {"content-encoding", "content-length"} | {s.lower() for s in extra_exclude}
return Response(
resp.content,
status=resp.status_code,
headers=_out_headers(resp, extra_exclude=skip),
content_type=resp.headers.get("Content-Type", ""),
)
# ---------------------------------------------------------------------------
# Custom /projects landing page
# ---------------------------------------------------------------------------
def get_projects_from_db(session_cookies=None):
"""Return active projects via LS API using session cookies (in-memory state).
Falls back to DB token, then SQLite direct read.
"""
# Method 1: Use the current session cookies (most reliable โ uses LS in-memory state)
if session_cookies:
try:
r = requests.get(
f"{LS_URL}/api/projects/?page_size=100",
cookies={k: v for k, v in session_cookies.items()},
timeout=10,
)
if r.status_code == 200:
data = r.json()
results = [(p["id"], p["title"]) for p in data.get("results", [])]
if results:
return results
except Exception as e:
print(f"[projects] Session-cookie call failed: {e}", flush=True)
# Method 2: SQLite direct read (may include stale rows โ filtered by _MANIFEST_IDS)
try:
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("SELECT id, title FROM project ORDER BY id").fetchall()
conn.close()
return rows
except Exception:
return []
def build_projects_page(user_email=""):
projects = sorted(get_projects_from_db(session_cookies=request.cookies), key=lambda x: x[0])
cards = ""
for pid, title in projects:
if _MANIFEST_IDS and str(pid) not in _MANIFEST_IDS:
continue
code, lang = _flag_for_title(title)
clean_title = _re.sub(r'^[\U0001F1E0-\U0001F1FF]{2}\s*', '', title)
if code:
flag_html = (
f''
)
else:
flag_html = ""
cards += f"""
Coming from Prolific? Do not log in here โ use the Start button in your Prolific study link instead.
Your login happens automatically when you click that link.
If you lost the link, return to app.prolific.com and open the study from there.
"""
@app.route("/user/login/", methods=["GET"])
@app.route("/user/login", methods=["GET"])
def login_get():
"""If already logged in, skip the login page; otherwise inject a clear banner."""
uid, _ = get_current_user(request)
if uid:
next_url = request.args.get("next", "/projects/")
if not next_url.startswith("/") or next_url in ("/", ""):
next_url = "/projects/"
return Response("", status=302, headers={"Location": next_url})
# Fetch LS login page and inject the SAGA banner at the top
try:
r = requests.get(f"{LS_URL}/user/login/", cookies=_ls_cookies(request),
headers=_fwd_headers(request), timeout=10)
except requests.exceptions.ConnectionError:
return _ls_unavailable()
if r.status_code != 200:
return _ls_unavailable()
html = _rewrite_hf_urls(r.text)
body_match = _re.search(r'(]*>)', html, _re.IGNORECASE)
if body_match:
insert_pos = body_match.end()
html = html[:insert_pos] + _LOGIN_BANNER + html[insert_pos:]
else:
html = _LOGIN_BANNER + html
fwd = _out_headers(r, extra_exclude={"content-encoding", "content-length"})
return Response(html, status=200, headers=fwd, content_type="text/html; charset=utf-8")
@app.route("/user/login/", methods=["POST"])
@app.route("/user/login", methods=["POST"])
def login_post():
"""Open a fresh server-side LS session to handle CSRF, then forward cookies to browser.
We never rely on the browser's csrftoken cookie because the Space runs inside an
iframe on huggingface.co โ cross-site context blocks SameSite=Lax cookies.
The proxy fetches a fresh CSRF token from LS directly and posts credentials
server-to-server. Cookies are returned as SameSite=None;Secure so they work
both in direct-URL access and in the HF iframe.
"""
try:
sess = requests.Session()
r0 = sess.get(f"{LS_URL}/user/login/", timeout=10)
if r0.status_code != 200:
return _ls_unavailable()
csrf_cookie = sess.cookies.get("csrftoken", "")
# Support any attribute order in the hidden input
m = (_re.search(r'name="csrfmiddlewaretoken"[^>]*value="([^"]+)"', r0.text) or
_re.search(r'value="([^"]+)"[^>]*name="csrfmiddlewaretoken"', r0.text))
csrf_token = m.group(1) if m else csrf_cookie
form = request.form.to_dict(flat=True)
form["csrfmiddlewaretoken"] = csrf_token
# allow_redirects=True so we get the final sessionid after Django session rotation
r1 = sess.post(
f"{LS_URL}/user/login/",
data=form,
headers={"Referer": f"{LS_URL}/user/login/",
"X-CSRFToken": csrf_token},
allow_redirects=True,
timeout=15,
)
if "/user/login" not in r1.url:
next_url = request.args.get("next") or request.form.get("next") or "/projects/"
if not next_url.startswith("/") or next_url in ("/", ""):
next_url = "/projects/"
out = Response("", status=302, headers={"Location": next_url})
# SameSite=None;Secure โ required for cross-site iframe on huggingface.co
for name, value in sess.cookies.items():
out.set_cookie(
name, value,
path="/",
max_age=1209600,
samesite="None",
httponly=(name == "sessionid"),
secure=True,
)
return out
# Failure: wrong credentials โ re-render login page with banner
err_html = r1.text if r1.text else ""
body_match = _re.search(r'(]*>)', err_html, _re.IGNORECASE)
if body_match:
insert_pos = body_match.end()
err_html = err_html[:insert_pos] + _LOGIN_BANNER + err_html[insert_pos:]
else:
err_html = _LOGIN_BANNER + err_html
return Response(err_html, status=200, content_type="text/html; charset=utf-8")
except requests.exceptions.ConnectionError:
return _ls_unavailable()
except Exception as e:
return Response(f"Login error: {e}", status=502, content_type="text/plain")
@app.route("/user/signup/", methods=["GET", "POST"])
@app.route("/user/signup", methods=["GET", "POST"])
def signup():
"""Proxy sign-up and re-set cookies with SameSite=None;Secure so the proxy can read them."""
if request.method == "GET":
return _proxy_stream("/user/signup/")
try:
sess = requests.Session()
r0 = sess.get(f"{LS_URL}/user/signup/", timeout=10)
if r0.status_code != 200:
return _ls_unavailable()
csrf_cookie = sess.cookies.get("csrftoken", "")
m = (_re.search(r'name="csrfmiddlewaretoken"[^>]*value="([^"]+)"', r0.text) or
_re.search(r'value="([^"]+)"[^>]*name="csrfmiddlewaretoken"', r0.text))
csrf_token = m.group(1) if m else csrf_cookie
form = request.form.to_dict(flat=True)
form["csrfmiddlewaretoken"] = csrf_token
r1 = sess.post(
f"{LS_URL}/user/signup/",
data=form,
headers={"Referer": f"{LS_URL}/user/signup/", "X-CSRFToken": csrf_token},
allow_redirects=True,
timeout=15,
)
if "/user/signup" not in r1.url:
out = Response("", status=302, headers={"Location": "/projects/"})
for name, value in sess.cookies.items():
out.set_cookie(name, value, path="/", max_age=1209600,
httponly=(name == "sessionid"),
samesite="None", secure=True)
return out
# Sign-up failed (validation errors) โ return LS response as-is
return Response(r1.text, status=r1.status_code,
content_type=r1.headers.get("Content-Type", "text/html"))
except requests.exceptions.ConnectionError:
return _ls_unavailable()
except Exception as e:
return Response(f"Signup error: {e}", status=502, content_type="text/plain")
@app.route("/user/logout/", methods=["GET", "POST"])
@app.route("/user/logout", methods=["GET", "POST"])
def logout():
"""Clear the server-side LS session and wipe browser cookies."""
try:
requests.get(f"{LS_URL}/user/logout/", cookies=_ls_cookies(request),
headers=_fwd_headers(request), allow_redirects=True, timeout=5)
except Exception:
pass
html = f"""
SAGA โ Logged Out
{FAVICON_TAG}
Your Prolific ID is recorded automatically. Do not share this link.
{PRIVACY_SCRIPT}
"""
resp = make_response(html)
if pid: resp.set_cookie("prolific_pid", pid, max_age=86400, samesite="Lax")
if cc: resp.set_cookie("prolific_cc", cc, max_age=86400, samesite="Lax")
return resp
# โโ Legacy per-language mode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
cc = cc_override or PROLIFIC_CC.get(lang, "")
if lang not in PROLIFIC_LANG_PROJECTS:
return Response("
Thank you for participating! You will evaluate a few sentence-level text completions.
Step 1: Complete each annotation task below (~5 min each). Step 2: You will be redirected back to Prolific automatically when done.
{task_links}
Your Prolific ID is recorded automatically. Do not share this link.
"""
resp = make_response(html)
if pid:
resp.set_cookie("prolific_pid", pid, max_age=86400, samesite="Lax")
if cc:
resp.set_cookie("prolific_cc", cc, max_age=86400, samesite="Lax")
return resp
@app.route("/", defaults={"path": ""}, methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
@app.route("/", methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"])
def catch_all(path):
full_path = "/" + path
if any(s in full_path for s in ["googletagmanager", "google-analytics", "hotjar"]):
return Response("", status=204)
if request.method != "GET":
return _proxy_stream(full_path)
url = f"{LS_URL}{full_path}"
if request.query_string:
url += "?" + request.query_string.decode()
try:
resp = requests.get(url, headers=_fwd_headers(request), cookies=_ls_cookies(request),
allow_redirects=False, stream=True, timeout=30)
except requests.exceptions.ConnectionError:
return _ls_unavailable()
except requests.exceptions.Timeout:
return Response("Request timed out.", status=504, content_type="text/plain")
# Intercept LS's "not authenticated" redirect โ forward it to our login page.
# Without this the browser would follow the redirect silently and the user
# would see the LS login form (which bypasses our proxy cookie logic).
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location", "")
# Normalise โ LS may return absolute or relative
if loc.startswith(LS_URL):
loc = loc[len(LS_URL):]
if loc.startswith("/user/login"):
# Expired / missing session โ send back to our custom login page
return Response("", status=302, headers={"Location": f"/user/login/?next={full_path}"})
# All other LS redirects pass through unchanged
return Response("", status=resp.status_code, headers=_out_headers(resp))
ct = resp.headers.get("Content-Type", "")
if "text/html" in ct and resp.status_code == 200:
html = resp.content.decode("utf-8", errors="replace")
# Rewrite absolute HF Space URLs โ relative so scripts/API calls stay on current domain
html = _rewrite_hf_urls(html)
html = _re.sub(r'',
'', html, flags=_re.DOTALL | _re.IGNORECASE)
tag = PRIVACY_SCRIPT
if "" in html:
html = html.replace("", FAVICON_TAG + tag + "", 1)
elif "