saga-annotation / ls_proxy.py
Hodfa71's picture
Fix project IDs (1-8), add SV, add per-project /start?project=N for Prolific
48c0978 verified
Raw
History Blame Contribute Delete
31.5 kB
"""
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>
&nbsp;|&nbsp;
<a href="/user/login/">Login</a>
</p>
{PRIVACY_SCRIPT}
</body>
</html>"""
# ---------------------------------------------------------------------------
# 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/<int:task_id>/annotations/", methods=["GET"])
@app.route("/api/tasks/<int:task_id>/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/<int:ann_id>/", methods=["DELETE"])
@app.route("/api/annotations/<int:ann_id>", 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("<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 &rarr; {task}
</a>
<p class="note">Your Prolific ID is recorded automatically. Do not share this link.</p>
</div>
{PRIVACY_SCRIPT}
</body>
</html>"""
resp = make_response(html)
if pid_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
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
def catch_all(path):
full_path = "/" + path
# 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)