""" ls_proxy.py โ€” Reverse proxy for Label Studio with: 1. Custom /projects landing page with flag emojis 2. Annotation privacy: non-admins only see their own annotations - GET /api/tasks//annotations/ is filtered server-side - "View all" / history panel is hidden client-side via injected JS (also prevents the TypeError: Cannot read properties of null (reading 'innerHTML')) 3. Prolific integration: /start?lang=XX&pid=PROLIFIC_PID&cc=COMPLETION_CODE - Stores PID + completion code in a session cookie - Redirects participant to the correct language project(s) - When all tasks done โ†’ JS redirects to Prolific with the completion code Run: nohup python3 -u ~/logs/ls_proxy.py >> ~/logs/ls_proxy.log 2>&1 & Then point ngrok to port 8081 instead of 8080. """ import json import re as _re import sqlite3 import requests from flask import Flask, request, Response, redirect, make_response app = Flask(__name__, static_folder=None) LS_URL = "http://localhost:8082" DB_PATH = "/home/hoda/.local/share/label-studio/label_studio.sqlite3" # Only the 8 canonical projects โ€” unlisted IDs get no flag and are hidden PROJECT_FLAGS = { "1": ("๐Ÿ‡ฎ๐Ÿ‡ธ", "รslenska"), # IS Likert "2": ("๐Ÿ‡ฎ๐Ÿ‡ธ", "รslenska"), # IS Pairwise "3": ("๐Ÿ‡ฉ๐Ÿ‡ฐ", "Dansk"), # DA Likert "4": ("๐Ÿ‡ฉ๐Ÿ‡ฐ", "Dansk"), # DA Pairwise "5": ("๐Ÿ‡ณ๐Ÿ‡ด", "Norsk"), # NB Likert "6": ("๐Ÿ‡ณ๐Ÿ‡ด", "Norsk"), # NB Pairwise "7": ("๐Ÿ‡ธ๐Ÿ‡ช", "Svenska"), # SV Likert "8": ("๐Ÿ‡ธ๐Ÿ‡ช", "Svenska"), # SV Pairwise } # Project IDs to show on the landing page (only canonical 8) ALLOWED_PROJECT_IDS = set(PROJECT_FLAGS.keys()) # Per-project metadata for single-project Prolific studies (?project=N) import os as _os PROLIFIC_PROJECT_META = { "1": {"flag": "๐Ÿ‡ฎ๐Ÿ‡ธ", "lang": "Icelandic / รslenska", "task": "Likert Rating", "cc": _os.environ.get("PROLIFIC_CC_P1", "ISLIK01")}, "2": {"flag": "๐Ÿ‡ฎ๐Ÿ‡ธ", "lang": "Icelandic / รslenska", "task": "Pairwise Comparison", "cc": _os.environ.get("PROLIFIC_CC_P2", "ISPAIR02")}, "3": {"flag": "๐Ÿ‡ฉ๐Ÿ‡ฐ", "lang": "Danish / Dansk", "task": "Likert Rating", "cc": _os.environ.get("PROLIFIC_CC_P3", "DALIK03")}, "4": {"flag": "๐Ÿ‡ฉ๐Ÿ‡ฐ", "lang": "Danish / Dansk", "task": "Pairwise Comparison", "cc": _os.environ.get("PROLIFIC_CC_P4", "DAPAIR04")}, "5": {"flag": "๐Ÿ‡ณ๐Ÿ‡ด", "lang": "Norwegian / Norsk", "task": "Likert Rating", "cc": _os.environ.get("PROLIFIC_CC_P5", "NBLIK05")}, "6": {"flag": "๐Ÿ‡ณ๐Ÿ‡ด", "lang": "Norwegian / Norsk", "task": "Pairwise Comparison", "cc": _os.environ.get("PROLIFIC_CC_P6", "NBPAIR06")}, "7": {"flag": "๐Ÿ‡ธ๐Ÿ‡ช", "lang": "Swedish / Svenska", "task": "Likert Rating", "cc": _os.environ.get("PROLIFIC_CC_P7", "SVLIK07")}, "8": {"flag": "๐Ÿ‡ธ๐Ÿ‡ช", "lang": "Swedish / Svenska", "task": "Pairwise Comparison", "cc": _os.environ.get("PROLIFIC_CC_P8", "SVPAIR08")}, } # Prolific: map lang code โ†’ list of (project_id, label) โ€” legacy per-language mode PROLIFIC_LANG_PROJECTS = { "is": [("1", "Likert Rating"), ("2", "Pairwise Comparison")], "da": [("3", "Likert Rating"), ("4", "Pairwise Comparison")], "nb": [("5", "Likert Rating"), ("6", "Pairwise Comparison")], "sv": [("7", "Likert Rating"), ("8", "Pairwise Comparison")], } # Legacy per-language completion codes 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", "SAGASV28"), } # Injected into every HTML page served through the proxy. # Non-admins: hides the "view all annotations" button/panel (prevents the # TypeError and stops annotators from seeing each other's work). # Admins: no change. FAVICON_TAG = '' PRIVACY_SCRIPT = """ """ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _fwd_headers(req, extra_exclude=()): """Build forwarding headers from the incoming request. Origin and Referer are rewritten to the LS host so Django's CSRF middleware sees a same-origin request (the proxy is on 8081, LS on 8080 โ€” a port mismatch triggers 403 if Origin is forwarded as-is). """ skip = {"host", "content-length", "transfer-encoding"} | set(extra_exclude) h = {k: v for k, v in req.headers if k.lower() not in skip} h["Host"] = "localhost:8080" # Rewrite Origin/Referer so they match the LS host, not the proxy port if "Origin" in h: h["Origin"] = LS_URL if "Referer" in h: h["Referer"] = h["Referer"].replace( req.host_url.rstrip("/"), LS_URL ) return h def _out_headers(resp, extra_exclude=()): """Build response headers to send back to the browser. Uses resp.raw.headers (urllib3 HTTPHeaderDict) instead of resp.headers (requests CaseInsensitiveDict) because the latter merges multiple Set-Cookie headers into one comma-joined string, breaking CSRF cookies. """ 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 = {} # session_key โ†’ (uid, is_admin, expires_at) _USER_CACHE_TTL = 300 # seconds def get_current_user(req): """Return (user_id, is_admin) for the current request's session. Caches result per session cookie for 5 minutes to avoid repeated whoami calls. """ import time as _time session_key = req.cookies.get("sessionid") or req.cookies.get("csrftoken", "") 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={}, 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 def _proxy_stream(path): """Streaming pass-through (keeps content-encoding so browser decompresses).""" url = f"{LS_URL}{path}" if request.query_string: url += "?" + request.query_string.decode() resp = requests.request( method=request.method, url=url, headers=_fwd_headers(request), data=request.get_data(), cookies={}, allow_redirects=False, stream=True, timeout=30, ) return Response( resp.iter_content(chunk_size=4096), status=resp.status_code, # Keep content-encoding: the raw (possibly compressed) bytes flow # through and the browser handles decompression. headers=_out_headers(resp), content_type=resp.headers.get("Content-Type", ""), ) def _proxy_buffered(path): """Non-streaming fetch โ€” requests auto-decompresses the body.""" 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={}, allow_redirects=False, timeout=30, # stream=False โ†’ automatic decompression ) def _respond_buffered(resp, extra_exclude=()): """Turn a buffered requests.Response into a Flask Response.""" # content-encoding must be stripped: body was already decompressed. skip = {"content-encoding", "content-length"} skip |= {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(): 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(): projects = get_projects_from_db() cards = "" for pid, title in projects: if str(pid) not in ALLOWED_PROJECT_IDS: continue flag, lang = PROJECT_FLAGS[str(pid)] clean_title = _re.sub(r'^[\U0001F1E0-\U0001F1FF]{2}\s*', '', title) flag_html = f'{flag}' cards += f"""
{flag_html}
{clean_title}
""" return f""" SAGA โ€” Annotation Projects {FAVICON_TAG}

SAGA Annotation Projects

Click a project below to start annotating.

{cards}
{PRIVACY_SCRIPT} """ # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/proxy/me", methods=["GET"]) def proxy_me(): """Returns {user_id, is_admin} for the current session. Used by PRIVACY_SCRIPT.""" uid, admin = get_current_user(request) return Response( json.dumps({"user_id": uid, "is_admin": admin}), content_type="application/json; charset=utf-8", ) @app.route("/", methods=["GET"]) def root_redirect(): """After login LS redirects to /. Send logged-in users to /projects/ instead.""" uid, _ = get_current_user(request) if uid: from flask import redirect return redirect("/projects/", code=302) # Not logged in โ€” let LS handle it (will redirect to /user/login/) return _proxy_stream("/") @app.route("/projects", methods=["GET"]) @app.route("/projects/", methods=["GET"]) def projects_landing(): return Response(build_projects_page(), 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//annotations/", methods=["GET"]) @app.route("/api/tasks//annotations", methods=["GET"]) def filter_task_annotations(task_id): """Non-admins only see their own annotations (allows resume; hides others).""" uid, admin = get_current_user(request) resp = _proxy_buffered(f"/api/tasks/{task_id}/annotations/") if admin or uid is None: # Admins and unauthenticated requests pass through unmodified. return _respond_buffered(resp) try: data = resp.json() own = [ann for ann in data if ann.get("completed_by") == 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//", methods=["DELETE"]) @app.route("/api/annotations/", methods=["DELETE"]) def guard_annotation_delete(ann_id): """Block non-admins from deleting annotations they don't own.""" 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", methods=["GET"]) def proxy_progress(): """Returns {done, total} for the current user in a given project.""" 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 = conn.execute( "SELECT COUNT(*) FROM task WHERE project_id=?", (proj,) ).fetchone()[0] conn.close() except Exception: done, total = 0, 0 return Response(json.dumps({"done": done, "total": total}), content_type="application/json") _START_CSS = """ * { 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: 520px; 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: 20px; } .badge { display: inline-block; background: #e8f0fe; color: #1a3a5c; border-radius: 20px; padding: 4px 12px; font-size: 12px; font-weight: 600; margin-bottom: 18px; } .task-btn { display: block; background: #1a3a5c; color: #fff; text-decoration: none; border-radius: 10px; padding: 16px 20px; margin-bottom: 12px; font-size: 16px; font-weight: 600; transition: background .15s; } .task-btn:hover { background: #2a5a8c; } .note { font-size: 12px; color: #999; margin-top: 16px; } """ @app.route("/start", methods=["GET"]) def prolific_start(): """ Prolific entry point โ€” two modes: Single-project (one Prolific study per LS project): /start?project=3&pid=PROLIFIC_PID Legacy per-language (both tasks for a language): /start?lang=da&pid=PROLIFIC_PID """ pid_param = request.args.get("pid", "") project = request.args.get("project", "").strip() lang = request.args.get("lang", "").lower() cc_override = request.args.get("cc", "") # โ”€โ”€ Single-project mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if project: meta = PROLIFIC_PROJECT_META.get(project) if not meta: return Response("

Unknown project. Please contact the researcher.

", status=400, content_type="text/html") cc = cc_override or meta["cc"] flag, lang_name, task = meta["flag"], meta["lang"], meta["task"] short_lang = lang_name.split("/")[0].strip() html = f""" SAGA โ€” {lang_name} {task} {FAVICON_TAG}
{flag} {lang_name}

{task}

You'll evaluate short text continuations in {short_lang}.
Takes about 5โ€“10 minutes. You'll be sent back to Prolific automatically when done.

{flag} Start → {task}

Your Prolific ID is recorded automatically. Do not share this link.

{PRIVACY_SCRIPT} """ resp = make_response(html) if pid_param: resp.set_cookie("prolific_pid", pid_param, max_age=86400, samesite="Lax") if cc: resp.set_cookie("prolific_cc", cc, max_age=86400, samesite="Lax") return resp # โ”€โ”€ Legacy per-language mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if lang not in PROLIFIC_LANG_PROJECTS: return Response("

Unknown language or project. Please contact the researcher.

", status=400, content_type="text/html") cc = cc_override or PROLIFIC_CC.get(lang, "") 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 = "".join( f'{flag} {lbl}' for p, lbl in projects ) html = f""" SAGA โ€” {lang_name} Evaluation {FAVICON_TAG}
{flag} {lang_name}

Parse Quality Study

Complete both tasks below (~10 min total).
You'll be redirected back to Prolific automatically when done.

{task_links}

Your Prolific ID is recorded automatically. Do not share this link.

{PRIVACY_SCRIPT} """ resp = make_response(html) if pid_param: resp.set_cookie("prolific_pid", pid_param, max_age=86400, samesite="Lax") if cc: resp.set_cookie("prolific_cc", cc, max_age=86400, samesite="Lax") return resp @app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) @app.route("/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) def catch_all(path): full_path = "/" + path # Block external analytics/tracking โ€” saves ~100ms per page load if any(s in full_path for s in ["googletagmanager", "google-analytics", "hotjar", "segment.io"]): return Response("", status=204) if request.method != "GET": return _proxy_stream(full_path) # For GET requests: start a streaming request so we can peek at content-type # from headers without buffering the body. url = f"{LS_URL}{full_path}" if request.query_string: url += "?" + request.query_string.decode() resp = requests.get( url, headers=_fwd_headers(request), cookies={}, allow_redirects=False, stream=True, timeout=30, ) ct = resp.headers.get("Content-Type", "") if "text/html" in ct and resp.status_code == 200: # HTML pages are small (React shell ~5 KB). Buffer, inject, return. # resp.content reads the stream and auto-decompresses. html = resp.content.decode("utf-8", errors="replace") # Strip Google Analytics / Tag Manager scripts before serving โ€” prevents # ~160ms external DNS+fetch calls on every page load. html = _re.sub( r']*(?:googletagmanager|google-analytics)[^>]*>.*?', '', html, flags=_re.DOTALL | _re.IGNORECASE, ) html = _re.sub( r']*/>[^<]*(?:googletagmanager|google-analytics)', '', html, flags=_re.IGNORECASE, ) html = _re.sub( r']*>.*?(?:googletagmanager|google-analytics).*?', '', html, flags=_re.DOTALL | _re.IGNORECASE, ) tag = PRIVACY_SCRIPT if "" in html: html = html.replace("", FAVICON_TAG + tag + "", 1) elif "" in html: html = html.replace("", FAVICON_TAG + tag + "", 1) else: html += tag # Forward original headers (especially Set-Cookie for CSRF token). # Drop encoding/length headers since we've modified the body. fwd = _out_headers(resp, extra_exclude={"content-encoding", "content-length"}) r = Response(html, status=200, headers=fwd, content_type="text/html; charset=utf-8") return r # Non-HTML (JS bundles, CSS, images, JSON): stream through. # iter_content() already decompresses (decode_content=True in urllib3), # so strip content-encoding to avoid the browser double-decompressing. 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( "LS proxy on port 8081 โ†’ custom /projects + annotation privacy โ†’ localhost:8080", flush=True, ) app.run(host="0.0.0.0", port=8081, threaded=True)