saga-annotation / ls_proxy_hf.py
Hodfa71's picture
Add Prolific guidance banner to login page
4ccfc1a verified
Raw
History Blame Contribute Delete
89.3 kB
"""
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 = '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 32 32\'%3E%3Crect width=\'32\' height=\'32\' rx=\'6\' fill=\'%231a3a5c\'/%3E%3Ctext x=\'16\' y=\'23\' font-family=\'Georgia,serif\' font-size=\'20\' font-weight=\'bold\' fill=\'%23e8c97a\' text-anchor=\'middle\'%3ES%3C/text%3E%3C/svg%3E">'
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 = """
<script>
(function () {
'use strict';
// โ”€โ”€ Mobile viewport + responsive CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(function mobileFix() {
var vp = document.querySelector('meta[name="viewport"]');
if (!vp) { vp = document.createElement('meta'); vp.name = 'viewport'; document.head.appendChild(vp); }
vp.content = 'width=device-width, initial-scale=1, maximum-scale=5';
var ms = document.createElement('style');
ms.textContent =
'@media (max-width: 768px) {'
+ ' body { overflow-x: hidden !important; }'
+ ' #__saga_progress { font-size: 11px !important; padding: 4px 8px !important; line-height: 1.4; }'
+ ' #__saga_submit_btn { bottom: 14px !important; right: 14px !important;'
+ ' padding: 9px 16px !important; font-size: 13px !important; }'
+ ' .lsf-main-content, [class*="sidepanel"] { min-width: unset !important; }'
+ ' .lsf-label-button, [class*="lsf-button"] { min-width: unset !important; }'
+ '}';
document.head.appendChild(ms);
})();
// โ”€โ”€ Null-guard for LS innerHTML bug โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(function patchInnerHTML() {
var desc = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
if (!desc || !desc.set) return;
var orig = desc.set;
Object.defineProperty(Element.prototype, 'innerHTML', {
set: function (v) { if (this != null) orig.call(this, v); },
get: desc.get, configurable: true,
});
})();
// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/); return m ? m[1] : '';
}
function getProject() {
var m = window.location.pathname.match(/\\/projects\\/(\\d+)\\//);
return m ? m[1] : null;
}
function prolificDone() {
var cc = document.cookie.match(/prolific_cc=([^;]+)/);
if (cc) {
window.location.href = 'https://app.prolific.com/submissions/complete?cc=' + cc[1];
} else {
window.location.href = '/projects/';
}
}
function goNextTask(proj) {
fetch('/api/projects/' + proj + '/next/', {headers: {'X-CSRFToken': getCsrf()}})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d && d.id) {
window.location.href = '/projects/' + proj + '/data?task=' + d.id;
} else {
prolificDone();
}
})
.catch(function() { prolificDone(); });
}
// โ”€โ”€ Auto-advance after Submit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(function autoNextTask() {
var origFetch = window.fetch;
window.fetch = function(url, opts) {
var p = origFetch.apply(this, arguments);
if (opts && opts.method === 'POST' && typeof url === 'string'
&& url.match(/\\/api\\/tasks\\/\\d+\\/annotations/)) {
p.then(function(r) {
if (r.ok) {
var proj = getProject();
if (proj) setTimeout(function() { goNextTask(proj); }, 900);
}
});
}
return p;
};
})();
// โ”€โ”€ Progress bar + Next button (single /proxy/init call) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(function ui() {
var proj = getProject();
if (!proj) return;
// Single API call returns {is_admin, done, total}
fetch('/proxy/init?project=' + proj)
.then(function(r) { return r.json(); })
.then(function(d) {
// โ”€โ”€ Progress bar โ”€โ”€
var bar = document.getElementById('__saga_progress');
if (!bar) {
bar = document.createElement('div');
bar.id = '__saga_progress';
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;'
+ 'background:#1a3a5c;color:#e8c97a;font-size:13px;font-weight:600;'
+ 'padding:5px 16px;text-align:center;font-family:sans-serif;'
+ 'box-shadow:0 2px 6px rgba(0,0,0,.25);';
document.body.appendChild(bar);
document.body.style.paddingTop = '30px';
}
var rem = d.total - d.done;
bar.textContent = d.total > 0
? (d.done + ' / ' + d.total + ' tasks done'
+ (rem > 0 ? ' ยท ' + rem + ' remaining' : ' โœ“ All done! Thank you.'))
: '';
// โ”€โ”€ "All done" overlay when no task is loaded but user is finished โ”€
var hasTask = window.location.search.indexOf('task=') !== -1;
var onData = window.location.pathname.indexOf('/data') !== -1;
if (onData && !hasTask && d.total > 0 && d.done >= d.total) {
if (!document.getElementById('__saga_done_overlay')) {
var cc = document.cookie.match(/prolific_cc=([^;]+)/);
var ccVal = cc ? cc[1] : '';
var overlay = document.createElement('div');
overlay.id = '__saga_done_overlay';
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;'
+ 'background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = '<div style="background:#fff;border-radius:16px;padding:40px 36px;'
+ 'max-width:460px;width:90%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3);">'
+ '<div style="font-size:48px;margin-bottom:12px;">&#10003;</div>'
+ '<h2 style="color:#16a34a;font-size:22px;margin-bottom:12px;">All tasks completed!</h2>'
+ '<p style="color:#555;font-size:14px;margin-bottom:24px;">'
+ 'Thank you for completing all ' + d.total + ' tasks.</p>'
+ (ccVal
? '<a href="https://app.prolific.com/submissions/complete?cc=' + ccVal + '" '
+ 'style="display:inline-block;background:#16a34a;color:#fff;text-decoration:none;'
+ 'border-radius:8px;padding:14px 32px;font-size:16px;font-weight:700;'
+ 'box-shadow:0 2px 8px rgba(0,0,0,.2);">Submit on Prolific &rarr;</a>'
+ '<p style="color:#888;font-size:12px;margin-top:12px;">Completion code: <b>'
+ ccVal + '</b></p>'
: '<p style="color:#333;font-size:15px;">Return to Prolific and submit your completion code.</p>'
)
+ '</div>';
document.body.appendChild(overlay);
}
}
// โ”€โ”€ Floating "Submit โœ“" button โ€” only on task pages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Clicks the native LS submit button; auto-advance then moves to next task.
if (hasTask && !document.getElementById('__saga_submit_btn')) {
var btn = document.createElement('button');
btn.id = '__saga_submit_btn';
btn.innerHTML = 'Submit &#10003;';
btn.title = 'Save your answer and go to the next task';
btn.style.cssText = 'position:fixed;bottom:28px;right:28px;z-index:10000;'
+ 'background:#16a34a;color:#fff;border:none;border-radius:8px;'
+ 'padding:12px 26px;font-size:15px;font-weight:700;cursor:pointer;'
+ 'box-shadow:0 3px 14px rgba(0,0,0,.35);letter-spacing:.3px;'
+ 'transition:background .15s,opacity .1s;';
btn.onmouseover = function() { btn.style.background = '#15803d'; };
btn.onmouseout = function() { btn.style.background = '#16a34a'; };
btn.onclick = function() {
// Try CSS class selector first (most reliable in LS 1.23)
var native = document.querySelector(
'[class*="submit"][class*="lsf-button"]:not([disabled]),'
+ '.lsf-button_look_primary:not([disabled])'
);
// Fallback: text content "Submit" (exact, case-insensitive, not "Update")
if (!native) {
native = Array.from(document.querySelectorAll('button')).find(function(b) {
return (b.textContent || '').trim().toLowerCase() === 'submit' && !b.disabled;
});
}
if (native) {
btn.style.opacity = '0.6';
native.click();
} else {
// Button not yet visible โ€” scroll down to reveal it
window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'});
}
};
document.body.appendChild(btn);
}
// โ”€โ”€ Privacy CSS (non-admins only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (d.is_admin) return;
var style = document.createElement('style');
style.textContent =
// Fix submit button cut off by overflow
'body { overflow-x: hidden !important; }'
+ '.lsf-main-content { overflow-x: hidden !important; }'
// Make submit button always fully visible
+ '[class*="submit"][class*="lsf-button"], .lsf-button_look_primary {'
+ ' position: relative !important; z-index: 100 !important; }'
// Hide annotation history panels
+ '[class*="annotations-list"],[class*="annotation-list"],[class*="history-"],'
+ '[class*="HistoryItem"],[data-testid="all-annotations"],'
+ '.ls-annotations-list,.lsf-annotators,.lsf-tabs-dm__extra { display:none!important; }'
+ '.lsf-table-row > .lsf-table__cell:nth-child(3),'
+ '.lsf-table-row > .lsf-table__cell:nth-child(7) { display:none!important; }';
document.head.appendChild(style);
function hideElements() {
document.querySelectorAll('button,[role="button"]').forEach(function(b) {
var t = (b.textContent || '').trim().toLowerCase();
if (t.indexOf('view all') !== -1 || t === 'all annotations')
b.style.cssText = 'display:none!important';
});
}
hideElements();
new MutationObserver(hideElements).observe(document.documentElement, {childList:true,subtree:true});
})
.catch(function() {});
window.addEventListener('popstate', function() {
var p2 = getProject();
if (p2) fetch('/proxy/init?project=' + p2)
.then(function(r) { return r.json(); })
.then(function(d) {
var bar = document.getElementById('__saga_progress');
if (bar) {
var rem = d.total - d.done;
bar.textContent = d.done + ' / ' + d.total + ' tasks done'
+ (rem > 0 ? ' ยท ' + rem + ' remaining' : ' โœ“ All done! Thank you.');
}
}).catch(function(){});
});
})();
// โ”€โ”€ Auto-recover from LS "task does not exist" error page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// When goNextTask navigates to a task that LS can't serve (already annotated
// by someone else, or queue race), LS shows an error. Detect it and skip.
(function recoverFromTaskError() {
function checkForError() {
var body = document.body ? document.body.innerText : '';
if (body.indexOf('does not exist') !== -1 || body.indexOf('no longer available') !== -1
|| body.indexOf('No More Tasks') !== -1 || body.indexOf('queue have been completed') !== -1) {
var proj = getProject();
if (proj) {
setTimeout(function() { prolificDone(); }, 1200);
}
}
}
// Check on load and after React re-renders
setTimeout(checkForError, 1500);
new MutationObserver(function() { checkForError(); })
.observe(document.documentElement, {childList: true, subtree: true});
})();
// โ”€โ”€ React Router intercept โ†’ hard reload for custom /projects/ page โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Also fires on load to catch direct navigation to /projects?page=N.
// Does NOT redirect /projects/ without page= param (that's our own custom page).
(function interceptProjectsNav() {
function checkPath() {
var p = window.location.pathname;
var q = window.location.search;
// /projects (no slash) = SPA internal route โ€” always redirect
// /projects/?page=N = SPA pagination โ€” redirect
// /projects/ = our custom landing page โ€” leave alone
if (p === '/projects' || (p === '/projects/' && q.indexOf('page=') !== -1)) {
window.location.replace('/projects/');
}
}
// Run immediately (catches direct URL navigation and pagination links)
checkPath();
var origPush = history.pushState, origReplace = history.replaceState;
history.pushState = function() { origPush.apply(this, arguments); checkPath(); };
history.replaceState = function() { origReplace.apply(this, arguments); checkPath(); };
window.addEventListener('popstate', checkPath);
})();
})();
</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 = """<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta http-equiv="refresh" content="5">
<title>SAGA โ€” Startingโ€ฆ</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#f5f5f5;}
.box{text-align:center;padding:40px;background:#fff;border-radius:12px;
box-shadow:0 2px 8px rgba(0,0,0,.1);}
h2{color:#333;margin-bottom:8px;}p{color:#777;}</style></head>
<body><div class="box"><h2>โณ Label Studio is startingโ€ฆ</h2>
<p>This page refreshes automatically every 5 seconds.</p></div></body></html>"""
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'<img class="flag-img" '
f'src="https://flagcdn.com/96x72/{code}.png" '
f'srcset="https://flagcdn.com/192x144/{code}.png 2x" '
f'alt="{lang}" title="{lang}" loading="lazy">'
)
else:
flag_html = ""
cards += f"""
<a class="card" href="/projects/{pid}/">
<div class="flag">{flag_html}</div>
<div class="title">{clean_title}</div>
</a>"""
user_bar = ""
if user_email:
user_bar = (
f'<div class="user-bar">'
f'&#10003; Logged in as <strong>{user_email}</strong>'
f'&nbsp;&nbsp;<a href="/user/logout/">Log out</a>'
f'</div>'
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAGA โ€” Annotation Projects</title>
{FAVICON_TAG}
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5; padding: 40px 20px; }}
.user-bar {{ background: #1a3a5c; color: #e8c97a; text-align: center;
padding: 8px 16px; font-size: 13px; margin: -40px -20px 32px;
position: sticky; top: 0; z-index: 100; }}
.user-bar a {{ color: #aed6f1; text-decoration: none; margin-left: 12px; }}
h1 {{ text-align: center; margin-bottom: 8px; font-size: 28px; color: #333; }}
p.sub {{ text-align: center; color: #777; margin-bottom: 32px; font-size: 14px; }}
.grid {{ display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; max-width: 1100px; margin: 0 auto; }}
.card {{ display: flex; flex-direction: column; align-items: center;
background: #fff; border-radius: 12px; padding: 24px 20px;
width: 240px; text-decoration: none; color: inherit;
box-shadow: 0 2px 8px rgba(0,0,0,.08); transition: transform .15s, box-shadow .15s; }}
.card:hover {{ transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.13); }}
.flag {{ margin-bottom: 12px; height: 72px; display: flex; align-items: center; justify-content: center; }}
.flag-img {{ width: 96px; height: 72px; object-fit: contain;
border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.15); }}
.title {{ font-size: 13px; font-weight: 600; color: #222; text-align: center; line-height: 1.4; }}
.admin-link {{ text-align: center; margin-top: 32px; font-size: 13px; color: #aaa; }}
.admin-link a {{ color: #5f9ea0; text-decoration: none; }}
@media (max-width: 540px) {{
body {{ padding: 0 0 40px; }}
.user-bar {{ margin: 0 0 24px; position: sticky; top: 0; }}
h1 {{ font-size: 22px; margin: 20px 0 6px; }}
p.sub {{ margin-bottom: 20px; }}
.grid {{ gap: 12px; padding: 0 12px; }}
.card {{ width: calc(50% - 6px); padding: 16px 10px; }}
.flag {{ height: 54px; margin-bottom: 8px; }}
.flag-img {{ width: 72px; height: 54px; }}
.title {{ font-size: 12px; }}
}}
@media (max-width: 320px) {{
.card {{ width: 100%; }}
}}
</style>
</head>
<body>
{user_bar}
<h1>SAGA Annotation Projects</h1>
<p class="sub">Click a project below to start annotating.</p>
<div class="grid">{cards}</div>
<p class="admin-link">
<a href="/projects-ls/">Full Label Studio projects page</a>
</p>
{PRIVACY_SCRIPT}
</body>
</html>"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/proxy/headers")
def proxy_headers():
"""Dump all incoming headers and cookies โ€” for browser debugging."""
data = {
"headers": dict(request.headers),
"cookies": list(request.cookies.keys()),
"method": request.method,
"url": request.url,
}
return Response(json.dumps(data, indent=2), content_type="application/json")
@app.route("/proxy/me")
def proxy_me():
uid, admin = get_current_user(request)
return Response(json.dumps({"user_id": uid, "is_admin": admin}),
content_type="application/json; charset=utf-8")
def _count_user_annotations(uid, proj):
"""Count distinct tasks annotated by uid in proj โ€” DB-first, LS API fallback."""
# Primary: query task_completion table directly (no network call needed)
try:
conn = sqlite3.connect(DB_PATH)
done = conn.execute(
"SELECT COUNT(DISTINCT tc.task_id) FROM task_completion tc "
"JOIN task t ON tc.task_id=t.id "
"WHERE tc.completed_by_id=? AND t.project_id=? AND tc.was_cancelled=0",
(uid, int(proj))
).fetchone()[0]
conn.close()
if done > 0:
return done
except Exception:
pass
# Fallback: use superuser token from DB to call LS API
try:
conn = sqlite3.connect(DB_PATH)
# Get token for any superuser
row = conn.execute(
"SELECT at.key FROM authtoken_token at "
"JOIN htx_user u ON at.user_id = u.id "
"WHERE u.is_superuser=1 ORDER BY u.id LIMIT 1"
).fetchone()
conn.close()
admin_token = row[0] if row else None
except Exception:
admin_token = None
if admin_token:
try:
r = requests.get(
f"{LS_URL}/api/tasks/?project={proj}&page_size=500",
headers={"Authorization": f"Token {admin_token}", "Host": "localhost:8080"},
timeout=10,
)
if r.status_code == 200:
data = r.json()
tasks = data if isinstance(data, list) else data.get("tasks", data.get("results", []))
done = 0
for t in tasks:
for ann in t.get("annotations", []):
if ann.get("was_cancelled"):
continue
cb = ann.get("completed_by")
cb_id = cb if isinstance(cb, int) else (cb.get("id") if isinstance(cb, dict) else None)
if cb_id == uid:
done += 1
break
return done
except Exception:
pass
return 0
@app.route("/proxy/init")
def proxy_init():
"""Single endpoint: returns {is_admin, done, total} in one round-trip."""
uid, admin = get_current_user(request)
proj = request.args.get("project")
done, total = 0, 0
if uid and proj:
total = _MANIFEST_TASKS.get(str(proj), 0)
done = _count_user_annotations(uid, proj)
return Response(
json.dumps({"is_admin": admin, "done": done, "total": total}),
content_type="application/json; charset=utf-8"
)
@app.route("/proxy/count-debug")
def proxy_count_debug():
"""Debug: test _count_user_annotations for a given uid+project (key-gated)."""
if request.args.get("key") != "saga2026":
return Response("forbidden", status=403)
uid = int(request.args.get("uid", 0))
proj = request.args.get("project", "4")
# Also test admin token retrieval
try:
conn = sqlite3.connect(DB_PATH)
tok_row = conn.execute("SELECT user_id, key FROM authtoken_token WHERE user_id=1").fetchone()
conn.close()
admin_token = tok_row[1] if tok_row else None
except Exception as e:
admin_token = None
tok_err = str(e)
# Test LS API call
ls_status, ls_tasks = None, None
if admin_token:
try:
r = requests.get(
f"{LS_URL}/api/tasks/?project={proj}&page_size=500",
headers={"Authorization": f"Token {admin_token}", "Host": "localhost:8080"},
timeout=10,
)
ls_status = r.status_code
if r.status_code == 200:
data = r.json()
tasks = data if isinstance(data, list) else data.get("tasks", data.get("results", []))
ls_tasks = len(tasks)
done = sum(1 for t in tasks for a in t.get("annotations", [])
if not a.get("was_cancelled") and
(a.get("completed_by") == uid or
(isinstance(a.get("completed_by"), dict) and
a["completed_by"].get("id") == uid)))
else:
done = -1
except Exception as e:
done = -2
ls_status = str(e)
else:
done = -3
# Also check task_completion directly
try:
conn2 = sqlite3.connect(DB_PATH)
db_done = conn2.execute(
"SELECT COUNT(DISTINCT tc.task_id) FROM task_completion tc "
"JOIN task t ON tc.task_id=t.id "
"WHERE tc.completed_by_id=? AND t.project_id=? AND tc.was_cancelled=0",
(uid, int(proj))
).fetchone()[0]
conn2.close()
except Exception as e:
db_done = str(e)
# Find actual LS DB path
import glob as _glob
db_files = sorted(_glob.glob("/data/**/*.sqlite3", recursive=True) +
_glob.glob("/label-studio/**/*.sqlite3", recursive=True))
# Count users in each candidate DB
db_user_counts = {}
for f in db_files[:10]:
try:
c = sqlite3.connect(f); n = c.execute("SELECT COUNT(*) FROM htx_user").fetchone()[0]; c.close()
db_user_counts[f] = n
except Exception:
db_user_counts[f] = "err"
result = {"uid": uid, "proj": proj, "admin_token_found": admin_token is not None,
"ls_status": ls_status, "ls_tasks": ls_tasks, "done": done,
"db_done": db_done, "ls_url": LS_URL, "DB_PATH": DB_PATH,
"db_files": db_user_counts,
"_count_result": _count_user_annotations(uid, proj)}
return Response(json.dumps(result, indent=2), content_type="application/json; charset=utf-8")
@app.route("/proxy/db-schema")
def proxy_db_schema():
"""Return schema of key tables for debugging (admin only via ?key=)."""
if request.args.get("key") != "saga2026":
return Response("forbidden", status=403)
try:
conn = sqlite3.connect(DB_PATH)
tables = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()]
result = {"tables": tables}
for t in tables:
if "org" in t.lower() or "token" in t.lower():
cols = [r[1] for r in conn.execute(f"PRAGMA table_info({t})").fetchall()]
result[t] = cols
conn.close()
return Response(json.dumps(result, indent=2), content_type="application/json; charset=utf-8")
except Exception as e:
return Response(json.dumps({"error": str(e)}), status=500, content_type="application/json; charset=utf-8")
@app.route("/proxy/ls-token")
def proxy_ls_token():
"""Return the LS API token for the authenticated user (admins only)."""
uid, admin = get_current_user(request)
if not uid or not admin:
return Response(json.dumps({"error": "unauthorized"}), status=401,
content_type="application/json; charset=utf-8")
try:
conn = sqlite3.connect(DB_PATH)
row = conn.execute(
"SELECT key FROM authtoken_token WHERE user_id=?", (uid,)
).fetchone()
conn.close()
if row:
return Response(json.dumps({"token": row[0]}),
content_type="application/json; charset=utf-8")
return Response(json.dumps({"error": "no token found"}), status=404,
content_type="application/json; charset=utf-8")
except Exception as e:
return Response(json.dumps({"error": str(e)}), status=500,
content_type="application/json; charset=utf-8")
@app.route("/proxy/diagnostic")
def proxy_diagnostic():
"""Returns admin-user status and Space health info."""
lines = []
# Check LS health
try:
r = requests.get(f"{LS_URL}/health", timeout=5)
lines.append(f"LS health: {r.status_code}")
except Exception as e:
lines.append(f"LS health: ERROR {e}")
# Check admin user in DB
try:
conn = sqlite3.connect(DB_PATH, timeout=5)
rows = conn.execute(
"SELECT id, email, is_active, is_superuser, is_staff, "
"substr(password,1,30) FROM htx_user"
).fetchall()
conn.close()
lines.append(f"htx_user rows: {len(rows)}")
for row in rows:
lines.append(f" id={row[0]} email={row[1]} active={row[2]} "
f"super={row[3]} staff={row[4]} pw_prefix={row[5]}")
except Exception as e:
lines.append(f"DB check: ERROR {e}")
# Verify changeme against stored hash (pure Python โ€” no Django setup needed)
try:
import hashlib, base64
conn = sqlite3.connect(DB_PATH, timeout=5)
rows2 = conn.execute("SELECT email, password FROM htx_user").fetchall()
conn.close()
for email, pw_hash in rows2:
# Format: pbkdf2_sha256$<iter>$<salt>$<hash>
parts = pw_hash.split("$", 3)
if len(parts) == 4 and parts[0] == "pbkdf2_sha256":
_, iters, salt, stored_b64 = parts
dk = hashlib.pbkdf2_hmac("sha256", "changeme".encode(),
salt.encode(), int(iters))
computed = base64.b64encode(dk).decode()
ok = (computed == stored_b64)
lines.append(f" verify('changeme', {email}): {ok} [iters={iters}]")
else:
lines.append(f" {email}: unrecognised hash format: {pw_hash[:20]}")
except Exception as e:
lines.append(f"verify error: {e}")
# Show ensure_admin log
log_path = "/data/ls/ensure_admin.log"
try:
if os.path.exists(log_path):
with open(log_path) as f:
lines.append("\n--- ensure_admin.log ---")
lines.extend(f.read().splitlines()[-20:])
else:
lines.append("\nensure_admin.log: not found")
except Exception as e:
lines.append(f"ensure_admin.log error: {e}")
return Response("\n".join(lines) + "\n", status=200, content_type="text/plain")
@app.route("/proxy/token-debug")
def proxy_token_debug():
"""Dump authtoken_token and test all tokens against LS API (admin only)."""
uid, admin = get_current_user(request)
if not uid or not admin:
return Response(json.dumps({"error": "unauthorized"}), status=401,
content_type="application/json; charset=utf-8")
try:
conn = sqlite3.connect(DB_PATH)
rows = conn.execute(
"SELECT at.key, at.user_id, u.email, u.is_superuser "
"FROM authtoken_token at JOIN htx_user u ON at.user_id = u.id"
).fetchall()
conn.close()
results = []
for (key, uid2, email, su) in rows:
# Test this token against internal LS API
try:
r = requests.get(f"{LS_URL}/api/projects/?page_size=1",
headers={"Authorization": f"Token {key}"},
timeout=5)
status = r.status_code
n_projects = len(r.json().get("results", [])) if status == 200 else 0
except Exception as e:
status = f"error: {e}"
n_projects = 0
results.append({
"key_prefix": key[:8],
"user_id": uid2,
"email": email,
"is_superuser": su,
"ls_api_status": status,
"ls_projects": n_projects,
})
return Response(json.dumps(results, indent=2),
content_type="application/json; charset=utf-8")
except Exception as e:
return Response(json.dumps({"error": str(e)}), status=500,
content_type="application/json; charset=utf-8")
@app.route("/proxy/manifest-check")
def proxy_manifest_check():
"""Return current _MANIFEST_IDS for debugging."""
uid, admin = get_current_user(request)
if not uid or not admin:
return Response(json.dumps({"error": "unauthorized"}), status=401,
content_type="application/json; charset=utf-8")
import os
p1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "projects", "manifest.json")
p2 = "/app/projects/manifest.json"
return Response(json.dumps({
"manifest_ids": sorted(_MANIFEST_IDS) if _MANIFEST_IDS else None,
"manifest_type": str(type(_MANIFEST_IDS)),
"p1_exists": os.path.exists(p1),
"p2_exists": os.path.exists(p2),
"file": __file__,
"proxy_version": "v6-manifest-check",
}, ensure_ascii=False, indent=2), content_type="application/json; charset=utf-8")
@app.route("/proxy/db-projects")
def proxy_db_projects():
"""Dump all rows from the project table plus debug info (admin only)."""
uid, admin = get_current_user(request)
if not uid or not admin:
return Response(json.dumps({"error": "unauthorized"}), status=401,
content_type="application/json; charset=utf-8")
debug = {}
# List DB-related files
data_dir = "/data/ls"
try:
files = sorted(os.listdir(data_dir))
db_files = [f for f in files if f.endswith(".sqlite3") or "sqlite" in f.lower()]
debug["db_files"] = db_files
debug["db_path"] = DB_PATH
debug["db_path_exists"] = os.path.exists(DB_PATH)
# Find ALL sqlite files in /data/
import subprocess
result = subprocess.run(["find", "/data", "-name", "*.sqlite3", "-type", "f"],
capture_output=True, text=True, timeout=5)
debug["all_sqlite_files"] = result.stdout.strip().split("\n") if result.stdout.strip() else []
# Also check /label-studio/data symlink target
import os.path
ls_data = "/label-studio/data"
if os.path.islink(ls_data):
debug["ls_data_link"] = os.readlink(ls_data)
elif os.path.isdir(ls_data):
debug["ls_data_dir"] = "is a directory (not symlink)"
except Exception as e:
debug["list_error"] = str(e)
# Get journal mode
try:
conn = sqlite3.connect(DB_PATH)
jm = conn.execute("PRAGMA journal_mode").fetchone()
debug["journal_mode"] = jm[0] if jm else "unknown"
rows = conn.execute(
"SELECT id, title, is_draft, is_published FROM project ORDER BY id"
).fetchall()
conn.close()
result = {
"debug": debug,
"projects": [{"id": r[0], "title": r[1], "is_draft": r[2], "is_published": r[3]} for r in rows]
}
return Response(json.dumps(result, ensure_ascii=False, indent=2),
content_type="application/json; charset=utf-8")
except Exception as e:
return Response(json.dumps({"error": str(e), "debug": debug}), status=500,
content_type="application/json; charset=utf-8")
@app.route("/proxy/reset-admin")
def proxy_reset_admin():
"""Force-reset admin password. Requires ?key=saga_reset secret."""
if request.args.get("key") != "saga_reset":
return Response("Forbidden", status=403, content_type="text/plain")
email = os.environ.get("LABEL_STUDIO_USERNAME", "admin@saga.is")
password = "12345" # matches ensure_admin.py hardcoded value
lines = []
# Show what password we're resetting to
lines.append(f"LABEL_STUDIO_USERNAME={email}")
lines.append(f"LABEL_STUDIO_PASSWORD={password} (len={len(password)})")
try:
import hashlib, base64
salt = base64.b64encode(os.urandom(12)).decode()
iters = 870000
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), iters)
pw_hash = f"pbkdf2_sha256${iters}${salt}${base64.b64encode(dk).decode()}"
lines.append(f"Hash generated (pure-Python): {pw_hash[:20]}โ€ฆ")
except Exception as e:
lines.append(f"Hash generation failed: {e}")
return Response("\n".join(lines) + "\n", status=500, content_type="text/plain")
try:
conn = sqlite3.connect(DB_PATH, timeout=10)
row = conn.execute("SELECT id FROM htx_user WHERE email=?", (email,)).fetchone()
if row:
conn.execute(
"UPDATE htx_user SET password=?, is_active=1, is_superuser=1, is_staff=1 "
"WHERE email=?", (pw_hash, email)
)
conn.commit()
lines.append(f"Updated {email} in htx_user")
else:
lines.append(f"User {email} not found โ€” running Django ORM creation")
os.environ.setdefault("DJANGO_SETTINGS_MODULE",
"label_studio.core.settings.label_studio")
import django; django.setup()
from django.contrib.auth import get_user_model
User = get_user_model()
user, created = User.objects.get_or_create(
email=email,
defaults={"username": email, "is_superuser": True,
"is_staff": True, "is_active": True},
)
user.username = email; user.is_superuser = True
user.is_staff = True; user.is_active = True
user.set_password(password); user.save()
lines.append(f"ORM: {'created' if created else 'updated'} {email}")
conn.close()
except Exception as e:
lines.append(f"ERROR: {e}")
return Response("\n".join(lines) + "\n", status=200, content_type="text/plain")
@app.route("/proxy/trigger-backup")
def proxy_trigger_backup():
"""Manually trigger a DB backup to HF. Requires admin session or ?key=saga2026."""
uid, admin = get_current_user(request)
if not admin and request.args.get("key") != "saga2026":
return Response("forbidden", status=403, content_type="text/plain")
try:
import subprocess
result = subprocess.run(
["python3", "/app/db_sync.py", "backup"],
timeout=120, capture_output=True, text=True,
)
out = result.stdout + result.stderr
return Response(f"exit={result.returncode}\n{out}", status=200, content_type="text/plain")
except Exception as e:
return Response(f"ERROR: {e}", status=500, content_type="text/plain")
@app.route("/proxy/db-download")
def proxy_db_download():
"""Serve the live SQLite DB for download. Requires ?key=saga_dl_2026."""
if request.args.get("key") != "saga_dl_2026":
return Response("forbidden", status=403, content_type="text/plain")
if not os.path.exists(DB_PATH):
return Response("DB not found", status=404, content_type="text/plain")
return Response(
open(DB_PATH, "rb").read(),
status=200,
content_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=label_studio.sqlite3"},
)
_LOGIN_BANNER = """
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
<style>
body, html { box-sizing: border-box; }
*, *::before, *::after { box-sizing: inherit; }
@media (max-width: 600px) {
.login-form-container, form[class*="login"] { padding: 16px !important; margin: 0 12px !important; }
input[type=email], input[type=password] { font-size: 16px !important; }
}
</style>
<div style="background:#1a3a5c;color:#e8c97a;text-align:center;padding:14px 16px;
font-family:sans-serif;font-size:14px;font-weight:600;letter-spacing:.3px;">
SAGA Annotation Platform
<div style="font-size:12px;font-weight:400;margin-top:4px;color:#aed6f1;">
Sign in with your email and password &nbsp;ยท&nbsp;
<a href="/user/signup/" style="color:#e8c97a;text-decoration:underline;">Create account</a>
</div>
</div>
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:8px;margin:16px 24px 0;
padding:12px 16px;font-family:sans-serif;font-size:13px;color:#856404;text-align:center;">
<b>Coming from Prolific?</b> Do not log in here โ€” use the <b>Start</b> button in your Prolific study link instead.
Your login happens automatically when you click that link.
If you lost the link, return to <a href="https://app.prolific.com" style="color:#856404;">app.prolific.com</a> and open the study from there.
</div>"""
@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'(<body[^>]*>)', 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'(<body[^>]*>)', 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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAGA โ€” Logged Out</title>
{FAVICON_TAG}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; }}
.box {{ background: #fff; border-radius: 12px; padding: 40px 36px;
max-width: 380px; text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,.1); }}
h2 {{ color: #333; margin-bottom: 12px; }}
p {{ color: #777; margin-bottom: 24px; font-size: 14px; }}
a {{ display: inline-block; background: #1a3a5c; color: #fff;
text-decoration: none; border-radius: 8px; padding: 10px 28px;
font-size: 15px; font-weight: 600; }}
a:hover {{ background: #15803d; }}
</style>
</head>
<body>
<div class="box">
<h2>You have been logged out</h2>
<p>Thank you for using SAGA Annotation.</p>
<a href="/user/login/">Log in again</a>
</div>
</body>
</html>"""
out = Response(html, status=200, content_type="text/html; charset=utf-8")
for name in ("sessionid", "csrftoken", "prolific_pid", "prolific_cc"):
for ss in ("None", "Lax"):
out.set_cookie(name, "", path="/", max_age=0, expires=0,
samesite=ss, secure=(ss == "None"))
return out
@app.route("/autologin", methods=["GET"])
def autologin():
"""Log in a Prolific participant โ€” two modes:
Credential mode (Prolific distributes pre-created accounts):
/autologin?email=CRED_EMAIL&password=CRED_PW&pid=PID&next=/projects/5/
PID mode (auto-create throwaway account from Prolific PID):
/autologin?pid=PROLIFIC_PID&next=/projects/4/
"""
pid = request.args.get("pid") or request.cookies.get("prolific_pid", "")
next_url = request.args.get("next", "/projects/")
if not next_url.startswith("/"):
next_url = "/projects/"
# Credential mode: explicit email+password provided (pre-created accounts)
cred_email = request.args.get("email", "").strip()
cred_password = request.args.get("password", "").strip()
if not pid and not cred_email:
return Response("", status=302, headers={"Location": "/user/login/"})
# Already logged in โ†’ go straight to dest
uid, _ = get_current_user(request)
if uid:
return Response("", status=302, headers={"Location": next_url})
if cred_email and cred_password:
email = cred_email
password = cred_password
else:
email = f"{pid}@prolific.saga"
password = f"saga-{pid[:16]}"
def _do_login(email, password):
"""Attempt LS login, return requests.Session on success or None on failure."""
sess = requests.Session()
r0 = sess.get(f"{LS_URL}/user/login/", timeout=10)
csrf = 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
r1 = sess.post(
f"{LS_URL}/user/login/",
data={"email": email, "password": password,
"csrfmiddlewaretoken": csrf_token},
headers={"Referer": f"{LS_URL}/user/login/", "X-CSRFToken": csrf_token},
allow_redirects=True, timeout=15,
)
if sess.cookies.get("sessionid", "") and "/user/login" not in r1.url:
return sess
return None
def _make_response_from_session(sess, next_url, pid):
out = Response("", status=302, headers={"Location": next_url})
for name, value in sess.cookies.items():
out.set_cookie(name, value, path="/", max_age=1209600,
httponly=(name == "sessionid"),
samesite="None", secure=True)
if pid: out.set_cookie("prolific_pid", pid, max_age=86400, samesite="Lax")
return out
# Try login first (account may already exist from a previous visit)
try:
sess = _do_login(email, password)
if sess:
return _make_response_from_session(sess, next_url, pid)
except Exception:
pass
# Credential mode: login failed โ€” accounts created via admin API may have
# plaintext/unhashed passwords. Self-heal: rewrite the DB hash, then retry.
if cred_email and cred_password:
try:
import hashlib, base64 as _b64
_salt = _b64.b64encode(os.urandom(12)).decode()
_iters = 870000
_dk = hashlib.pbkdf2_hmac("sha256", password.encode(), _salt.encode(), _iters)
_pw = f"pbkdf2_sha256${_iters}${_salt}${_b64.b64encode(_dk).decode()}"
_conn = sqlite3.connect(DB_PATH, timeout=10)
_rows = _conn.execute(
"UPDATE htx_user SET password=?, is_active=1 WHERE email=?",
(_pw, email)
).rowcount
_conn.commit(); _conn.close()
print(f"[autologin] self-healed hash for {email} ({_rows} row)", flush=True)
if _rows > 0:
sess2 = _do_login(email, password)
if sess2:
return _make_response_from_session(sess2, next_url, pid)
except Exception as _e:
print(f"[autologin] self-heal failed: {_e}", flush=True)
# Login failed โ†’ account doesn't exist yet, create it via signup
try:
sess2 = requests.Session()
r2 = sess2.get(f"{LS_URL}/user/signup/", timeout=10)
csrf_cookie2 = sess2.cookies.get("csrftoken", "")
m2 = (_re.search(r'name="csrfmiddlewaretoken"[^>]*value="([^"]+)"', r2.text) or
_re.search(r'value="([^"]+)"[^>]*name="csrfmiddlewaretoken"', r2.text))
csrf_token2 = m2.group(1) if m2 else csrf_cookie2
r3 = sess2.post(
f"{LS_URL}/user/signup/",
data={"email": email, "password": password, "password_confirm": password,
"csrfmiddlewaretoken": csrf_token2},
headers={"Referer": f"{LS_URL}/user/signup/", "X-CSRFToken": csrf_token2},
allow_redirects=True, timeout=15,
)
session_cookie2 = sess2.cookies.get("sessionid", "")
if session_cookie2:
out = Response("", status=302, headers={"Location": next_url})
for name, value in sess2.cookies.items():
out.set_cookie(name, value, path="/", max_age=1209600,
httponly=(name == "sessionid"),
samesite="None", secure=True)
if pid: out.set_cookie("prolific_pid", pid, max_age=86400, samesite="Lax")
return out
except Exception:
pass
# Fallback โ€” send to manual login
return Response("", status=302, headers={"Location": f"/user/login/?next={next_url}"})
@app.route("/projects", methods=["GET"])
@app.route("/projects/", methods=["GET"])
def projects_landing():
uid, _ = get_current_user(request)
if not uid:
return Response("", status=302,
headers={"Location": "/user/login/?next=/projects/"})
# Check if user has a prolific_cc cookie โ€” if so, show completion page when done
prolific_cc = request.cookies.get("prolific_cc", "")
if prolific_cc:
# Find which project this CC belongs to
proj_id = None
proj_meta = None
for pid, meta in PROLIFIC_PROJECT_META.items():
if meta.get("cc") == prolific_cc:
proj_id = pid
proj_meta = meta
break
if proj_id and proj_meta:
done = _count_user_annotations(uid, int(proj_id))
total = _MANIFEST_TASKS.get(str(proj_id), 0)
if total > 0 and done >= total:
flag = proj_meta.get("flag", "")
lang = proj_meta.get("lang", "")
task = proj_meta.get("task", "")
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAGA โ€” Thank You!</title>
{FAVICON_TAG}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f8f0; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; padding: 20px; }}
.box {{ background: #fff; border-radius: 16px; padding: 40px 36px;
max-width: 480px; width: 100%; box-shadow: 0 4px 20px rgba(0,0,0,.1);
text-align: center; }}
h1 {{ color: #16a34a; font-size: 28px; margin-bottom: 8px; }}
.sub {{ color: #555; font-size: 15px; margin-bottom: 28px; }}
.cc-box {{ background: #f0fdf4; border: 2px solid #16a34a; border-radius: 10px;
padding: 20px; margin-bottom: 28px; }}
.cc-label {{ font-size: 13px; color: #555; margin-bottom: 6px; }}
.cc-code {{ font-size: 32px; font-weight: 800; letter-spacing: 3px; color: #15803d;
font-family: monospace; }}
.btn {{ display: inline-block; background: #16a34a; color: #fff; text-decoration: none;
border-radius: 8px; padding: 14px 32px; font-size: 16px; font-weight: 700;
box-shadow: 0 2px 8px rgba(0,0,0,.2); }}
.btn:hover {{ background: #15803d; }}
.note {{ color: #888; font-size: 13px; margin-top: 20px; }}
</style>
</head>
<body>
<div class="box">
<h1>&#10003; All Done!</h1>
<p class="sub">{flag} {lang} โ€” {task}<br>Thank you for completing all {total} tasks.</p>
<div class="cc-box">
<div class="cc-label">Your Prolific completion code:</div>
<div class="cc-code">{prolific_cc}</div>
</div>
<a class="btn" href="https://app.prolific.com/submissions/complete?cc={prolific_cc}">
Submit on Prolific &rarr;
</a>
<p class="note">Copy the code above if the button doesn't work.</p>
</div>
</body>
</html>"""
return Response(html, status=200, content_type="text/html; charset=utf-8")
# Look up user email for the status bar
try:
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT email FROM htx_user WHERE id=?", (uid,)).fetchone()
conn.close()
user_email = row[0] if row else ""
except Exception:
user_email = ""
return Response(build_projects_page(user_email), status=200, content_type="text/html; charset=utf-8")
@app.route("/projects-ls/", methods=["GET"])
@app.route("/projects-ls", methods=["GET"])
def projects_ls():
return _proxy_stream("/projects/")
@app.route("/api/tasks/<int:task_id>/", methods=["GET"])
@app.route("/api/tasks/<int:task_id>", methods=["GET"])
def filter_task_data(task_id):
"""Strip other users' embedded annotations so LS shows a blank form to each annotator."""
uid, admin = get_current_user(request)
resp = _proxy_buffered(f"/api/tasks/{task_id}/")
if admin:
return _respond_buffered(resp)
try:
data = resp.json()
if "annotations" in data:
def _cb_id(ann):
cb = ann.get("completed_by")
return cb.get("id") if isinstance(cb, dict) else cb
if uid is not None:
data["annotations"] = [a for a in data["annotations"] if _cb_id(a) == uid]
else:
data["annotations"] = []
return Response(json.dumps(data), status=resp.status_code,
content_type="application/json; charset=utf-8")
except Exception:
return _respond_buffered(resp)
@app.route("/api/tasks/<int:task_id>/annotations/", methods=["GET", "POST"])
@app.route("/api/tasks/<int:task_id>/annotations", methods=["GET", "POST"])
def filter_task_annotations(task_id):
uid, admin = get_current_user(request)
if request.method == "POST":
resp = _proxy_buffered(f"/api/tasks/{task_id}/annotations/")
# Send email notification on successful annotation submission
if resp.status_code in (200, 201):
try:
ann = resp.json()
project_id = ann.get("project")
annotator_email = "unknown"
project_title = f"project {project_id}"
try:
conn = sqlite3.connect(DB_PATH, timeout=3)
row = conn.execute("SELECT email FROM htx_user WHERE id=?", (uid,)).fetchone()
if row:
annotator_email = row[0]
prow = conn.execute("SELECT title FROM projects_project WHERE id=?",
(project_id,)).fetchone()
if prow:
project_title = prow[0]
conn.close()
except Exception:
pass
# Email only when annotator finishes ALL tasks in this project
try:
conn2 = sqlite3.connect(DB_PATH, timeout=3)
done_count = conn2.execute(
"SELECT COUNT(*) FROM task_completion tc JOIN task t ON tc.task_id=t.id "
"WHERE tc.completed_by_id=? AND t.project_id=? AND tc.was_cancelled=0",
(uid, project_id)
).fetchone()[0]
total_count = conn2.execute(
"SELECT COUNT(*) FROM task t WHERE t.project_id=? "
"AND NOT EXISTS (SELECT 1 FROM core_deletedrow d "
"WHERE CAST(d.object_id AS INTEGER)=t.id)",
(project_id,)
).fetchone()[0]
conn2.close()
if total_count > 0 and done_count >= total_count:
threading.Thread(
target=_send_completion_email,
args=(project_title, annotator_email, done_count, total_count),
daemon=True,
).start()
except Exception as exc:
print(f"[notify] completion check failed: {exc}", flush=True)
except Exception as exc:
print(f"[notify] parse error after POST annotation: {exc}", flush=True)
# Backup DB immediately after each annotation
threading.Thread(
target=_backup_db, daemon=True
).start()
return _respond_buffered(resp)
# GET โ€” filter annotations by user for non-admins
resp = _proxy_buffered(f"/api/tasks/{task_id}/annotations/")
if admin or uid is None:
return _respond_buffered(resp)
try:
data = resp.json()
def _cb_id(ann):
cb = ann.get("completed_by")
return cb.get("id") if isinstance(cb, dict) else cb
own = [ann for ann in data if _cb_id(ann) == uid]
return Response(json.dumps(own), status=resp.status_code,
content_type="application/json; charset=utf-8")
except Exception:
return _respond_buffered(resp)
@app.route("/api/annotations/<int:ann_id>/", methods=["DELETE"])
@app.route("/api/annotations/<int:ann_id>", methods=["DELETE"])
def guard_annotation_delete(ann_id):
uid, admin = get_current_user(request)
if admin:
return _proxy_stream(f"/api/annotations/{ann_id}/")
if uid is None:
return Response('{"detail":"Forbidden"}', status=403, content_type="application/json")
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT completed_by_id FROM task_completion WHERE id=?", (ann_id,)).fetchone()
conn.close()
if row and row[0] == uid:
return _proxy_stream(f"/api/annotations/{ann_id}/")
return Response('{"detail":"Forbidden"}', status=403, content_type="application/json")
@app.route("/proxy/progress")
def proxy_progress():
uid, _ = get_current_user(request)
proj = request.args.get("project")
if not uid or not proj:
return Response(json.dumps({"done": 0, "total": 0}), content_type="application/json")
try:
conn = sqlite3.connect(DB_PATH)
done = conn.execute(
"SELECT COUNT(*) FROM task_completion tc JOIN task t ON tc.task_id=t.id "
"WHERE tc.completed_by_id=? AND t.project_id=? AND tc.was_cancelled=0",
(uid, proj)
).fetchone()[0]
total = _MANIFEST_TASKS.get(str(proj), 0)
conn.close()
except Exception:
done, total = 0, 0
return Response(json.dumps({"done": done, "total": total}), content_type="application/json")
@app.route("/proxy/session-check")
def proxy_session_check():
"""Debug: show session status and which cookies the proxy received."""
uid, admin = get_current_user(request)
cookie_names = sorted(request.cookies.keys())
ls_cookie_names = sorted(_ls_cookies(request).keys())
has_sid = "sessionid" in request.cookies
try:
r = requests.get(f"{LS_URL}/api/current-user/whoami",
cookies=_ls_cookies(request), timeout=5,
allow_redirects=False)
whoami = r.status_code
whoami_body = r.text[:200] if r.status_code != 200 else r.json().get("email","?")
except Exception as e:
whoami, whoami_body = "ERR", str(e)
result = {
"uid": uid, "is_admin": admin,
"cookies_received": cookie_names,
"ls_cookies_forwarded": ls_cookie_names,
"has_sessionid": has_sid,
"whoami_status": whoami,
"whoami_email": whoami_body,
}
return Response(json.dumps(result, indent=2), content_type="application/json")
@app.route("/go/<int:project_id>/", methods=["GET"])
@app.route("/go/<int:project_id>", methods=["GET"])
def go_to_project(project_id):
"""After autologin, redirect participant straight to their first available task."""
uid, _ = get_current_user(request)
if not uid:
return Response("", status=302, headers={"Location": f"/user/login/?next=/go/{project_id}/"})
try:
conn = sqlite3.connect(DB_PATH)
row = conn.execute(
"SELECT at.key FROM authtoken_token at "
"JOIN htx_user u ON at.user_id = u.id "
"WHERE u.is_superuser=1 ORDER BY u.id LIMIT 1"
).fetchone()
conn.close()
admin_token = row[0] if row else None
except Exception:
admin_token = None
if admin_token:
try:
r = requests.get(
f"{LS_URL}/api/projects/{project_id}/next/",
headers={"Authorization": f"Token {admin_token}", "Host": "localhost:8080"},
cookies=_ls_cookies(request), timeout=10,
)
d = r.json() if r.status_code == 200 else {}
if d.get("id"):
return Response("", status=302,
headers={"Location": f"/projects/{project_id}/data?task={d['id']}"})
except Exception:
pass
return Response("", status=302, headers={"Location": f"/projects/{project_id}/data"})
@app.route("/start", methods=["GET"])
def prolific_start():
"""Prolific entry point.
Single-project mode (one Prolific study per LS project):
/start?project=4&pid=PROLIFIC_PID
Legacy per-language mode (both tasks for a language):
/start?lang=da&pid=PROLIFIC_PID
"""
pid = request.args.get("pid", "")
project = request.args.get("project", "").strip()
lang = request.args.get("lang", "").lower()
cc_override = request.args.get("cc", "")
cred_email = request.args.get("email", "").strip()
cred_pw = request.args.get("password", "").strip()
# โ”€โ”€ Single-project mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if project:
meta = PROLIFIC_PROJECT_META.get(project)
if not meta:
return Response("<h2>Unknown project. Please contact the researcher.</h2>",
status=400, content_type="text/html")
cc = cc_override or meta["cc"]
flag = meta["flag"]
lang_name = meta["lang"]
task = meta["task"]
est_min = meta.get("est_min", 30)
short_lang = lang_name.split("/")[0].strip()
n_tasks = _MANIFEST_TASKS.get(str(project), 0)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SAGA โ€” {lang_name} {task}</title>
{FAVICON_TAG}
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa; display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px; }}
.card {{ background: #fff; border-radius: 16px; padding: 40px 36px; max-width: 560px;
width: 100%; box-shadow: 0 4px 24px rgba(0,0,0,.1); text-align: center; }}
.badge {{ display: inline-block; background: #eef2f7; color: #1a3a5c; border-radius: 20px;
padding: 4px 14px; font-size: 13px; font-weight: 600; margin-bottom: 16px; }}
h1 {{ font-size: 22px; color: #1a2a3a; margin-bottom: 16px; }}
.steps {{ text-align: left; background: #f0f4f8; border-radius: 10px; padding: 16px 20px;
margin-bottom: 24px; font-size: 13.5px; color: #333; line-height: 2; }}
.steps b {{ color: #1a3a5c; }}
.task-btn {{ display: block; background: #1a3a5c; color: #fff; text-decoration: none;
border-radius: 10px; padding: 14px 20px; margin-bottom: 12px;
font-size: 15px; font-weight: 600; transition: background .15s; }}
.task-btn:hover {{ background: #2a5a8c; }}
.note {{ font-size: 12px; color: #999; margin-top: 16px; }}
</style>
</head>
<body>
<div class="card">
<div class="badge">{flag} {lang_name}</div>
<h1>{task}</h1>
<div class="steps">
<b>Tasks:</b> {n_tasks} short text evaluations<br>
<b>Estimated time:</b> approximately {est_min} minutes<br>
<b>When done:</b> you will be sent back to Prolific automatically
</div>
<a class="task-btn" href="/autologin?pid={pid}&email={cred_email}&password={cred_pw}&next=/go/{project}/">
{flag} Start &rarr; {task}
</a>
<p class="note">Your Prolific ID is recorded automatically. Do not share this link.</p>
</div>
{PRIVACY_SCRIPT}
</body>
</html>"""
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("<h2>Unknown language. Please contact the researcher.</h2>",
status=400, content_type="text/html")
projects = PROLIFIC_LANG_PROJECTS[lang]
flag = {"is": "๐Ÿ‡ฎ๐Ÿ‡ธ", "da": "๐Ÿ‡ฉ๐Ÿ‡ฐ", "nb": "๐Ÿ‡ณ๐Ÿ‡ด", "sv": "๐Ÿ‡ธ๐Ÿ‡ช"}.get(lang, "")
lang_name = {"is": "Icelandic / รslenska", "da": "Danish / Dansk",
"nb": "Norwegian / Norsk", "sv": "Swedish / Svenska"}.get(lang, lang.upper())
task_links = ""
for proj_id, label in projects:
task_links += f"""
<a class="task-btn" href="/autologin?pid={pid}&next=/projects/{proj_id}/">
{flag} {label}
</a>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SAGA โ€” {lang_name} Evaluation</title>
{FAVICON_TAG}
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f7fa; display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px; }}
.card {{ background: #fff; border-radius: 16px; padding: 40px 36px; max-width: 560px;
width: 100%; box-shadow: 0 4px 24px rgba(0,0,0,.1); text-align: center; }}
h1 {{ font-size: 22px; color: #1a2a3a; margin-bottom: 8px; }}
p {{ color: #555; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }}
.steps {{ text-align: left; background: #f0f4f8; border-radius: 10px; padding: 16px 20px;
margin-bottom: 28px; font-size: 13.5px; color: #333; line-height: 2; }}
.steps b {{ color: #1a3a5c; }}
.task-btn {{ display: block; background: #1a3a5c; color: #fff; text-decoration: none;
border-radius: 10px; padding: 14px 20px; margin-bottom: 12px;
font-size: 15px; font-weight: 600; transition: background .15s; }}
.task-btn:hover {{ background: #2a5a8c; }}
.note {{ font-size: 12px; color: #999; margin-top: 16px; }}
</style>
</head>
<body>
<div class="card">
<h1>{flag} {lang_name} โ€” Parse Quality Study</h1>
<p>Thank you for participating! You will evaluate a few sentence-level text completions.</p>
<div class="steps">
<b>Step 1:</b> Complete each annotation task below (~5 min each).<br>
<b>Step 2:</b> You will be redirected back to Prolific automatically when done.
</div>
{task_links}
<p class="note">Your Prolific ID is recorded automatically. Do not share this link.</p>
</div>
</body>
</html>"""
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("/<path:path>", 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'<script[^>]*(?:googletagmanager|google-analytics)[^>]*>.*?</script>',
'', html, flags=_re.DOTALL | _re.IGNORECASE)
tag = PRIVACY_SCRIPT
if "</head>" in html:
html = html.replace("</head>", FAVICON_TAG + tag + "</head>", 1)
elif "</body>" in html:
html = html.replace("</body>", FAVICON_TAG + tag + "</body>", 1)
else:
html += tag
fwd = _out_headers(resp, extra_exclude={"content-encoding", "content-length"})
return Response(html, status=200, headers=fwd, content_type="text/html; charset=utf-8")
return Response(resp.iter_content(chunk_size=4096), status=resp.status_code,
headers=_out_headers(resp, extra_exclude={"content-encoding"}), content_type=ct)
if __name__ == "__main__":
print("SAGA proxy on :7860 โ†’ Label Studio on :8080", flush=True)
app.run(host="0.0.0.0", port=7860, threaded=True)