Spaces:
Running
Running
| """ | |
| 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;">✓</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 →</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 ✓'; | |
| 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'✓ Logged in as <strong>{user_email}</strong>' | |
| f' <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 | |
| # --------------------------------------------------------------------------- | |
| 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") | |
| 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 | |
| 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" | |
| ) | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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 ยท | |
| <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>""" | |
| 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") | |
| 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") | |
| 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") | |
| 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 | |
| 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}"}) | |
| 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>✓ 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 → | |
| </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") | |
| def projects_ls(): | |
| return _proxy_stream("/projects/") | |
| 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) | |
| 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) | |
| 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") | |
| 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") | |
| 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") | |
| 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"}) | |
| 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 → {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 | |
| 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) | |