Spaces:
Sleeping
Sleeping
deploy: labeling server
Browse files- labeling/static/app.js +27 -1
- labeling/static/index.html +1 -1
- spaces/space_entry.py +7 -0
- src/aamcq/annotation/api.py +15 -0
labeling/static/app.js
CHANGED
|
@@ -4,6 +4,7 @@ const AXES = ["art_style", "color", "art_medium", "lighting"];
|
|
| 4 |
const TOKEN_STORAGE_KEY = "aamcq_token";
|
| 5 |
const THEME_STORAGE_KEY = "aamcq_theme";
|
| 6 |
const ROUNDS_KEY = "aamcq_rounds_done";
|
|
|
|
| 7 |
const FIRST_SESSION_CAP = 20;
|
| 8 |
const REPEAT_SESSION_CAP = 10;
|
| 9 |
|
|
@@ -53,13 +54,38 @@ function nextSessionCap() {
|
|
| 53 |
return rounds === 0 ? FIRST_SESSION_CAP : REPEAT_SESSION_CAP;
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
async function registerFresh(cap) {
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if (!resp.ok) {
|
| 59 |
throw new Error(
|
| 60 |
"No ?token= in URL and anonymous registration is disabled on this server."
|
| 61 |
);
|
| 62 |
}
|
|
|
|
| 63 |
const { token } = await resp.json();
|
| 64 |
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
| 65 |
return token;
|
|
|
|
| 4 |
const TOKEN_STORAGE_KEY = "aamcq_token";
|
| 5 |
const THEME_STORAGE_KEY = "aamcq_theme";
|
| 6 |
const ROUNDS_KEY = "aamcq_rounds_done";
|
| 7 |
+
const PASSWORD_SESSION_KEY = "aamcq_access_password";
|
| 8 |
const FIRST_SESSION_CAP = 20;
|
| 9 |
const REPEAT_SESSION_CAP = 10;
|
| 10 |
|
|
|
|
| 54 |
return rounds === 0 ? FIRST_SESSION_CAP : REPEAT_SESSION_CAP;
|
| 55 |
}
|
| 56 |
|
| 57 |
+
async function attemptRegister(cap, password) {
|
| 58 |
+
const params = new URLSearchParams({ cap: String(cap) });
|
| 59 |
+
if (password) params.set("password", password);
|
| 60 |
+
return fetch(`/api/register?${params.toString()}`, { method: "POST" });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
async function registerFresh(cap) {
|
| 64 |
+
// Try with whatever password we have cached (could be empty).
|
| 65 |
+
let password = sessionStorage.getItem(PASSWORD_SESSION_KEY) || "";
|
| 66 |
+
let resp = await attemptRegister(cap, password);
|
| 67 |
+
|
| 68 |
+
// 403 means server wants a password — prompt (and re-prompt on mismatch).
|
| 69 |
+
while (resp.status === 403) {
|
| 70 |
+
sessionStorage.removeItem(PASSWORD_SESSION_KEY);
|
| 71 |
+
const entered = window.prompt(
|
| 72 |
+
password
|
| 73 |
+
? "Wrong access password. Try again:"
|
| 74 |
+
: "Enter the access password to start labeling:"
|
| 75 |
+
);
|
| 76 |
+
if (entered == null) {
|
| 77 |
+
throw new Error("Access password required.");
|
| 78 |
+
}
|
| 79 |
+
password = entered;
|
| 80 |
+
resp = await attemptRegister(cap, password);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
if (!resp.ok) {
|
| 84 |
throw new Error(
|
| 85 |
"No ?token= in URL and anonymous registration is disabled on this server."
|
| 86 |
);
|
| 87 |
}
|
| 88 |
+
if (password) sessionStorage.setItem(PASSWORD_SESSION_KEY, password);
|
| 89 |
const { token } = await resp.json();
|
| 90 |
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
| 91 |
return token;
|
labeling/static/index.html
CHANGED
|
@@ -41,6 +41,6 @@
|
|
| 41 |
<span id="error"></span>
|
| 42 |
</footer>
|
| 43 |
</main>
|
| 44 |
-
<script src="/app.js?v=
|
| 45 |
</body>
|
| 46 |
</html>
|
|
|
|
| 41 |
<span id="error"></span>
|
| 42 |
</footer>
|
| 43 |
</main>
|
| 44 |
+
<script src="/app.js?v=11"></script>
|
| 45 |
</body>
|
| 46 |
</html>
|
spaces/space_entry.py
CHANGED
|
@@ -14,6 +14,7 @@ Env vars:
|
|
| 14 |
AAMCQ_PER_ANNOTATOR_CAP default: 20
|
| 15 |
AAMCQ_LABELS_PER_ITEM default: 3
|
| 16 |
AAMCQ_BACKUP_INTERVAL default: 60 (seconds)
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
@@ -41,6 +42,7 @@ MCQ_PATH = DATA_DIR / "mcq_unlabeled.jsonl"
|
|
| 41 |
BACKUP_INTERVAL = int(os.environ.get("AAMCQ_BACKUP_INTERVAL", "60"))
|
| 42 |
PER_ANNOTATOR_CAP = int(os.environ.get("AAMCQ_PER_ANNOTATOR_CAP", "20"))
|
| 43 |
LABELS_PER_ITEM = int(os.environ.get("AAMCQ_LABELS_PER_ITEM", "3"))
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _require_token() -> str:
|
|
@@ -141,7 +143,12 @@ def main() -> int:
|
|
| 141 |
anonymous_register=True,
|
| 142 |
max_labels_per_item=LABELS_PER_ITEM,
|
| 143 |
max_labels_per_annotator=PER_ANNOTATOR_CAP,
|
|
|
|
| 144 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
@app.on_event("startup")
|
| 147 |
async def _start_backup() -> None:
|
|
|
|
| 14 |
AAMCQ_PER_ANNOTATOR_CAP default: 20
|
| 15 |
AAMCQ_LABELS_PER_ITEM default: 3
|
| 16 |
AAMCQ_BACKUP_INTERVAL default: 60 (seconds)
|
| 17 |
+
AAMCQ_ACCESS_PASSWORD optional; if set, /api/register requires it
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
|
|
|
| 42 |
BACKUP_INTERVAL = int(os.environ.get("AAMCQ_BACKUP_INTERVAL", "60"))
|
| 43 |
PER_ANNOTATOR_CAP = int(os.environ.get("AAMCQ_PER_ANNOTATOR_CAP", "20"))
|
| 44 |
LABELS_PER_ITEM = int(os.environ.get("AAMCQ_LABELS_PER_ITEM", "3"))
|
| 45 |
+
ACCESS_PASSWORD = os.environ.get("AAMCQ_ACCESS_PASSWORD") or None
|
| 46 |
|
| 47 |
|
| 48 |
def _require_token() -> str:
|
|
|
|
| 143 |
anonymous_register=True,
|
| 144 |
max_labels_per_item=LABELS_PER_ITEM,
|
| 145 |
max_labels_per_annotator=PER_ANNOTATOR_CAP,
|
| 146 |
+
access_password=ACCESS_PASSWORD,
|
| 147 |
)
|
| 148 |
+
if ACCESS_PASSWORD:
|
| 149 |
+
print("access password gate: ON")
|
| 150 |
+
else:
|
| 151 |
+
print("access password gate: OFF (set AAMCQ_ACCESS_PASSWORD to enable)")
|
| 152 |
|
| 153 |
@app.on_event("startup")
|
| 154 |
async def _start_backup() -> None:
|
src/aamcq/annotation/api.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import sqlite3
|
| 7 |
from pathlib import Path
|
|
@@ -41,6 +42,7 @@ def create_app(
|
|
| 41 |
anonymous_register: bool = False,
|
| 42 |
max_labels_per_item: int = 3,
|
| 43 |
max_labels_per_annotator: int | None = None,
|
|
|
|
| 44 |
) -> FastAPI:
|
| 45 |
"""Labeling server.
|
| 46 |
|
|
@@ -58,6 +60,10 @@ def create_app(
|
|
| 58 |
+ token on demand, so a single public URL can serve any number of
|
| 59 |
concurrent anonymous annotators (each browser session = one annotator).
|
| 60 |
Intended for public-URL crowdsourcing.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
"""
|
| 62 |
db_path = Path(db_path or DEFAULT_DB)
|
| 63 |
image_dir = Path(image_dir or DEFAULT_IMAGE_DIR)
|
|
@@ -72,6 +78,7 @@ def create_app(
|
|
| 72 |
app.state.anonymous_register = anonymous_register
|
| 73 |
app.state.max_labels_per_item = max_labels_per_item
|
| 74 |
app.state.max_labels_per_annotator = max_labels_per_annotator
|
|
|
|
| 75 |
|
| 76 |
def get_conn() -> sqlite3.Connection:
|
| 77 |
return app.state.conn
|
|
@@ -111,6 +118,7 @@ def create_app(
|
|
| 111 |
@app.post("/api/register")
|
| 112 |
def api_register(
|
| 113 |
cap: int | None = Query(default=None, ge=1, le=10000),
|
|
|
|
| 114 |
conn: sqlite3.Connection = Depends(get_conn),
|
| 115 |
):
|
| 116 |
"""Mint a fresh anonymous annotator. Only enabled when anonymous_register.
|
|
@@ -118,9 +126,16 @@ def create_app(
|
|
| 118 |
Optional `?cap=N` sets a per-annotator label cap that overrides the
|
| 119 |
server default (used by the frontend to give the first session a
|
| 120 |
larger quota than subsequent ones).
|
|
|
|
|
|
|
|
|
|
| 121 |
"""
|
| 122 |
if not app.state.anonymous_register:
|
| 123 |
raise HTTPException(status_code=404, detail="anonymous register disabled")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
existing = {row["annotator_id"] for row in conn.execute(
|
| 125 |
"SELECT annotator_id FROM annotators"
|
| 126 |
)}
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import hmac
|
| 6 |
import os
|
| 7 |
import sqlite3
|
| 8 |
from pathlib import Path
|
|
|
|
| 42 |
anonymous_register: bool = False,
|
| 43 |
max_labels_per_item: int = 3,
|
| 44 |
max_labels_per_annotator: int | None = None,
|
| 45 |
+
access_password: str | None = None,
|
| 46 |
) -> FastAPI:
|
| 47 |
"""Labeling server.
|
| 48 |
|
|
|
|
| 60 |
+ token on demand, so a single public URL can serve any number of
|
| 61 |
concurrent anonymous annotators (each browser session = one annotator).
|
| 62 |
Intended for public-URL crowdsourcing.
|
| 63 |
+
|
| 64 |
+
`access_password`: if set, `/api/register` requires a matching
|
| 65 |
+
`?password=` query param (constant-time compared). Cheap anti-spam
|
| 66 |
+
gate for public Spaces — existing tokens keep working regardless.
|
| 67 |
"""
|
| 68 |
db_path = Path(db_path or DEFAULT_DB)
|
| 69 |
image_dir = Path(image_dir or DEFAULT_IMAGE_DIR)
|
|
|
|
| 78 |
app.state.anonymous_register = anonymous_register
|
| 79 |
app.state.max_labels_per_item = max_labels_per_item
|
| 80 |
app.state.max_labels_per_annotator = max_labels_per_annotator
|
| 81 |
+
app.state.access_password = access_password
|
| 82 |
|
| 83 |
def get_conn() -> sqlite3.Connection:
|
| 84 |
return app.state.conn
|
|
|
|
| 118 |
@app.post("/api/register")
|
| 119 |
def api_register(
|
| 120 |
cap: int | None = Query(default=None, ge=1, le=10000),
|
| 121 |
+
password: str | None = Query(default=None, max_length=256),
|
| 122 |
conn: sqlite3.Connection = Depends(get_conn),
|
| 123 |
):
|
| 124 |
"""Mint a fresh anonymous annotator. Only enabled when anonymous_register.
|
|
|
|
| 126 |
Optional `?cap=N` sets a per-annotator label cap that overrides the
|
| 127 |
server default (used by the frontend to give the first session a
|
| 128 |
larger quota than subsequent ones).
|
| 129 |
+
|
| 130 |
+
If `access_password` was set at startup, `?password=` must match
|
| 131 |
+
(constant-time compared) or we return 403.
|
| 132 |
"""
|
| 133 |
if not app.state.anonymous_register:
|
| 134 |
raise HTTPException(status_code=404, detail="anonymous register disabled")
|
| 135 |
+
expected = app.state.access_password
|
| 136 |
+
if expected:
|
| 137 |
+
if not password or not hmac.compare_digest(password, expected):
|
| 138 |
+
raise HTTPException(status_code=403, detail="wrong access password")
|
| 139 |
existing = {row["annotator_id"] for row in conn.execute(
|
| 140 |
"SELECT annotator_id FROM annotators"
|
| 141 |
)}
|