Spaces:
Running
Running
| """ | |
| 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/<id>/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 = '<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">' | |
| PRIVACY_SCRIPT = """ | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| // Global null-guard: LS has a bug where it calls .innerHTML on a null | |
| // element when rendering the "view all" panel. Patch Element.prototype | |
| // so the setter silently no-ops on null/undefined targets. | |
| (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, | |
| }); | |
| })(); | |
| // Auto-advance to next task after Submit (works for all users) | |
| (function autoNextTask() { | |
| function getCsrf() { | |
| var m = document.cookie.match(/csrftoken=([^;]+)/); return m ? m[1] : ''; | |
| } | |
| function goNextTask() { | |
| var pm = window.location.pathname.match(/\/projects\/(\d+)\//); | |
| if (!pm) return; | |
| var proj = pm[1]; | |
| // Use label stream (annotator-accessible) instead of DM actions (admin-only) | |
| fetch('/api/projects/' + proj + '/next/', { | |
| headers: {'X-CSRFToken': getCsrf()}, | |
| }) | |
| .then(function(r) { return r.json(); }) | |
| .then(function(d) { | |
| var id = d && d.id; | |
| if (id) window.location.href = '/projects/' + proj + '/data?task=' + id; | |
| }) | |
| .catch(function() {}); | |
| } | |
| // Intercept fetch to detect annotation POST completion | |
| 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) setTimeout(goNextTask, 800); }); | |
| } | |
| return p; | |
| }; | |
| })(); | |
| // Show per-user progress bar at top of annotation pages | |
| // If Prolific PID cookie is set and all tasks done โ redirect to Prolific | |
| (function progressBar() { | |
| function getProlificInfo() { | |
| var cc = null, pid = null; | |
| document.cookie.split(';').forEach(function(c) { | |
| var p = c.trim(); | |
| if (p.startsWith('prolific_cc=')) cc = decodeURIComponent(p.slice(12)); | |
| if (p.startsWith('prolific_pid=')) pid = decodeURIComponent(p.slice(13)); | |
| }); | |
| return {cc: cc, pid: pid}; | |
| } | |
| function showProgress() { | |
| var pm = window.location.pathname.match(/\/projects\/(\d+)\//); | |
| if (!pm) return; | |
| var proj = pm[1]; | |
| fetch('/proxy/progress?project=' + proj) | |
| .then(function(r) { return r.json(); }) | |
| .then(function(d) { | |
| var existing = document.getElementById('__saga_progress'); | |
| if (!existing) { | |
| existing = document.createElement('div'); | |
| existing.id = '__saga_progress'; | |
| existing.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#1f2937;color:#f9fafb;' | |
| + 'font-size:13px;padding:4px 16px;z-index:9999;text-align:center;font-family:sans-serif;'; | |
| document.body.appendChild(existing); | |
| document.body.style.paddingTop = '32px'; | |
| } | |
| var remaining = d.total - d.done; | |
| if (remaining > 0) { | |
| existing.textContent = 'Your progress: ' + d.done + ' / ' + d.total + ' done โ ' + remaining + ' remaining'; | |
| } else { | |
| // All tasks done โ check if this is a Prolific session | |
| var info = getProlificInfo(); | |
| if (info.cc) { | |
| existing.style.background = '#065f46'; | |
| existing.innerHTML = 'โ All done! Redirecting you back to Prolificโฆ'; | |
| setTimeout(function() { | |
| window.location.href = 'https://app.prolific.com/submissions/complete?cc=' + info.cc; | |
| }, 1800); | |
| } else { | |
| existing.textContent = 'Your progress: ' + d.done + ' / ' + d.total + ' done โ All done!'; | |
| } | |
| } | |
| }) | |
| .catch(function() {}); | |
| } | |
| // Show on task annotation pages | |
| if (window.location.search.indexOf('task=') !== -1) showProgress(); | |
| // Re-check after each annotation submit (fetch intercept fires goNextTask, so refresh after nav) | |
| window.addEventListener('popstate', showProgress); | |
| })(); | |
| // Intercept React Router client-side navigation to /projects/ โ hard reload | |
| // so the proxy can serve the custom flags page instead of the LS projects list. | |
| (function interceptProjectsNav() { | |
| function checkPath() { | |
| var p = window.location.pathname; | |
| if (p === '/projects' || p === '/projects/') { | |
| window.location.replace('/projects/'); | |
| } | |
| } | |
| var origPush = history.pushState; | |
| var origReplace = history.replaceState; | |
| history.pushState = function () { origPush.apply(this, arguments); checkPath(); }; | |
| history.replaceState = function () { origReplace.apply(this, arguments); checkPath(); }; | |
| window.addEventListener('popstate', checkPath); | |
| })(); | |
| fetch('/proxy/me') | |
| .then(function (r) { return r.json(); }) | |
| .then(function (me) { | |
| if (me.is_admin) return; | |
| // --- CSS: hide annotation-history / "view all" / annotator identity --- | |
| var style = document.createElement('style'); | |
| style.textContent = | |
| '[class*="annotations-list"],' | |
| + '[class*="annotation-list"],' | |
| + '[class*="history-"],' | |
| + '[class*="HistoryItem"],' | |
| + '[data-testid="all-annotations"],' | |
| + '.ls-annotations-list { display: none !important; }' | |
| // Hide annotator avatars in task list (lsf-annotators) but not the nav avatar | |
| + '.lsf-annotators { display: none !important; }' | |
| // Hide bottom stats bar ("Submitted annotations: 393") from non-admins | |
| + '.lsf-tabs-dm__extra { display: none !important; }' | |
| // Hide Completed (col 3) and Annotated-by (col 7) data cells by position | |
| + '.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); | |
| // --- MutationObserver: hide "view all" buttons + "Annotated by" column --- | |
| function hideElements() { | |
| // Hide "View all" / "All annotations" buttons | |
| document.querySelectorAll('button, [role="button"]').forEach(function (btn) { | |
| var txt = (btn.textContent || btn.innerText || '').trim().toLowerCase(); | |
| if (txt.indexOf('view all') !== -1 || txt === 'all annotations') { | |
| btn.style.cssText = 'display:none!important'; | |
| } | |
| }); | |
| // Hide "Completed" and "Annotated by" column headers + data cells | |
| var HIDE_COLS = ['Annotated by', 'Completed']; | |
| document.querySelectorAll('.lsf-th-content').forEach(function (th) { | |
| if (HIDE_COLS.indexOf((th.textContent || '').trim()) === -1) return; | |
| // Walk up to the top-level header cell | |
| var headerCell = th.closest('th, [role="columnheader"], [class*="lsf-table__head-cell"]') || th.parentElement; | |
| if (!headerCell) return; | |
| headerCell.style.cssText = 'display:none!important'; | |
| // Find column index among siblings to hide matching data cells | |
| var siblings = Array.from((headerCell.parentElement || {}).children || []); | |
| var colIdx = siblings.indexOf(headerCell); | |
| if (colIdx < 0) return; | |
| document.querySelectorAll('[class*="lsf-table__body"] tr, [class*="GridBody"] tr').forEach(function (row) { | |
| var cell = row.children[colIdx]; | |
| if (cell) cell.style.cssText = 'display:none!important'; | |
| }); | |
| }); | |
| } | |
| hideElements(); | |
| var obs = new MutationObserver(hideElements); | |
| obs.observe(document.documentElement, { childList: true, subtree: true }); | |
| }) | |
| .catch(function () {}); | |
| })(); | |
| </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'<span class="flag-emoji" title="{lang}">{flag}</span>' | |
| cards += f""" | |
| <a class="card" href="/projects/{pid}/"> | |
| <div class="flag">{flag_html}</div> | |
| <div class="title">{clean_title}</div> | |
| </a>""" | |
| 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; }} | |
| 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; }} | |
| .flag-emoji {{ font-size: 52px; line-height: 1; }} | |
| .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; }} | |
| .admin-link a:hover {{ text-decoration: underline; }} | |
| </style> | |
| </head> | |
| <body> | |
| <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> | |
| | | |
| <a href="/user/login/">Login</a> | |
| </p> | |
| {PRIVACY_SCRIPT} | |
| </body> | |
| </html>""" | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| 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", | |
| ) | |
| 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("/") | |
| def projects_landing(): | |
| return Response(build_projects_page(), status=200, content_type="text/html; charset=utf-8") | |
| def projects_ls(): | |
| return _proxy_stream("/projects/") | |
| 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) | |
| 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") | |
| 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; } | |
| """ | |
| 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("<h2>Unknown project. Please contact the researcher.</h2>", | |
| 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"""<!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>{_START_CSS}</style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="badge">{flag} {lang_name}</div> | |
| <h1>{task}</h1> | |
| <p>You'll evaluate short text continuations in {short_lang}.<br> | |
| Takes about <b>5โ10 minutes</b>. You'll be sent back to Prolific automatically when done.</p> | |
| <a class="task-btn" href="/projects/{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_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("<h2>Unknown language or project. Please contact the researcher.</h2>", | |
| 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'<a class="task-btn" href="/projects/{p}/">{flag} {lbl}</a>' | |
| for p, lbl in projects | |
| ) | |
| 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>{_START_CSS}</style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="badge">{flag} {lang_name}</div> | |
| <h1>Parse Quality Study</h1> | |
| <p>Complete both tasks below (~10 min total).<br> | |
| You'll be redirected back to Prolific automatically when done.</p> | |
| {task_links} | |
| <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_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 | |
| 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'<script[^>]*(?:googletagmanager|google-analytics)[^>]*>.*?</script>', | |
| '', html, flags=_re.DOTALL | _re.IGNORECASE, | |
| ) | |
| html = _re.sub( | |
| r'<script[^>]*/>[^<]*(?:googletagmanager|google-analytics)', | |
| '', html, flags=_re.IGNORECASE, | |
| ) | |
| html = _re.sub( | |
| r'<noscript[^>]*>.*?(?:googletagmanager|google-analytics).*?</noscript>', | |
| '', 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 | |
| # 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) | |