Achim Rabus commited on
Commit
92348d4
·
1 Parent(s): 91dbb61

Fix image display in HF Space iframe: samesite=none cookie + global image cache

Browse files

HF Space runs the app in an iframe at hf.space embedded in huggingface.co.
These have different eTLD+1, so samesite=lax cookies are not sent on
cross-site requests — each API call arrived with a fresh session, meaning
the image uploaded in session A was never found by the subsequent
GET /api/image/{id} request (session B) → 404 → placeholder stayed visible.

Fix:
- Set samesite=none + secure=True when POLYSCRIPTOR_PROFILE=hf_space_demo
- Add global_image_cache as cross-session fallback so image retrieval
succeeds even if the session cookie is lost

Also restores multi-target drop zone (viewerScroll + viewerPlaceholder)
as secondary UX improvement from the earlier working version.

web/polyscriptor_server.py CHANGED
@@ -244,6 +244,15 @@ _SESSION_TTL_SECONDS = 7200
244
  # port. Default is unchanged for existing deployments.
245
  _SESSION_COOKIE = os.environ.get("POLYSCRIPTOR_SESSION_COOKIE", "polyscriptor_session")
246
 
 
 
 
 
 
 
 
 
 
247
 
248
  # ---------------------------------------------------------------------------
249
  # Per-user sessions — Phase 1 of multi-user refactoring
@@ -320,13 +329,17 @@ async def session_middleware(request: Request, call_next):
320
  response = await call_next(request)
321
 
322
  if created or session_id != session.session_id:
323
- response.set_cookie(
324
  key=_SESSION_COOKIE,
325
  value=session.session_id,
326
  httponly=True,
327
- samesite="lax",
328
  max_age=_SESSION_TTL_SECONDS,
329
  )
 
 
 
 
 
330
  return response
331
 
332
 
@@ -1331,7 +1344,7 @@ async def unload_engine(request: Request):
1331
  def _register_image(session: UserSession, pil_image: Image.Image, filename: str, save_path: Path) -> str:
1332
  """Store a PIL image in the session's cache and return its image_id."""
1333
  image_id = str(uuid.uuid4())
1334
- session.image_cache[image_id] = {
1335
  "path": save_path,
1336
  "xml_path": None,
1337
  "pil_image": pil_image,
@@ -1343,6 +1356,8 @@ def _register_image(session: UserSession, pil_image: Image.Image, filename: str,
1343
  "result_slots": {},
1344
  "primary_result_slot": None,
1345
  }
 
 
1346
  return image_id
1347
 
1348
 
@@ -1602,9 +1617,12 @@ async def upload_xml(request: Request, image_id: str, file: UploadFile = File(..
1602
  @app.get("/api/image/{image_id}")
1603
  async def get_image(request: Request, image_id: str):
1604
  session = _get_session(request)
1605
- if image_id not in session.image_cache:
 
1606
  raise HTTPException(404, "Image not found")
1607
- return FileResponse(str(session.image_cache[image_id]["path"]))
 
 
1608
 
1609
 
1610
  @app.get("/api/image/{image_id}/info")
 
244
  # port. Default is unchanged for existing deployments.
245
  _SESSION_COOKIE = os.environ.get("POLYSCRIPTOR_SESSION_COOKIE", "polyscriptor_session")
246
 
247
+ # HuggingFace Space: app runs in an iframe (hf.space) embedded in huggingface.co.
248
+ # These have different eTLD+1, so samesite=lax cookies are NOT sent on cross-site requests.
249
+ # We need samesite=none + secure=True so the session cookie survives across all API calls.
250
+ _HF_SPACE = os.environ.get("POLYSCRIPTOR_PROFILE", "").strip().lower() == "hf_space_demo"
251
+
252
+ # Cross-session image cache — allows GET /api/image/{id} to succeed even if
253
+ # the session cookie was lost (e.g. HF Space iframe cross-site requests).
254
+ global_image_cache: Dict[str, dict] = {}
255
+
256
 
257
  # ---------------------------------------------------------------------------
258
  # Per-user sessions — Phase 1 of multi-user refactoring
 
329
  response = await call_next(request)
330
 
331
  if created or session_id != session.session_id:
332
+ cookie_kwargs: dict = dict(
333
  key=_SESSION_COOKIE,
334
  value=session.session_id,
335
  httponly=True,
 
336
  max_age=_SESSION_TTL_SECONDS,
337
  )
338
+ if _HF_SPACE:
339
+ cookie_kwargs.update({"samesite": "none", "secure": True})
340
+ else:
341
+ cookie_kwargs["samesite"] = "lax"
342
+ response.set_cookie(**cookie_kwargs)
343
  return response
344
 
345
 
 
1344
  def _register_image(session: UserSession, pil_image: Image.Image, filename: str, save_path: Path) -> str:
1345
  """Store a PIL image in the session's cache and return its image_id."""
1346
  image_id = str(uuid.uuid4())
1347
+ img_data = {
1348
  "path": save_path,
1349
  "xml_path": None,
1350
  "pil_image": pil_image,
 
1356
  "result_slots": {},
1357
  "primary_result_slot": None,
1358
  }
1359
+ session.image_cache[image_id] = img_data
1360
+ global_image_cache[image_id] = img_data
1361
  return image_id
1362
 
1363
 
 
1617
  @app.get("/api/image/{image_id}")
1618
  async def get_image(request: Request, image_id: str):
1619
  session = _get_session(request)
1620
+ img_data = session.image_cache.get(image_id) or global_image_cache.get(image_id)
1621
+ if img_data is None:
1622
  raise HTTPException(404, "Image not found")
1623
+ if image_id not in session.image_cache:
1624
+ session.image_cache[image_id] = img_data
1625
+ return FileResponse(str(img_data["path"]))
1626
 
1627
 
1628
  @app.get("/api/image/{image_id}/info")
web/static/components/image-viewer.js CHANGED
@@ -6,10 +6,32 @@ import { state, emit, on, api, fitZoom } from '../app.js';
6
 
7
  const $ = id => document.getElementById(id);
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  export function initImageViewer() {
10
  const uploadArea = $('upload-area');
11
  const fileInput = $('file-input');
12
  const xmlInput = $('xml-input');
 
 
 
 
 
 
 
 
 
13
 
14
  // Click to browse image
15
  uploadArea.addEventListener('click', () => fileInput.click());
@@ -19,24 +41,30 @@ export function initImageViewer() {
19
  if (fileInput.files.length > 0) uploadFile(fileInput.files[0]);
20
  });
21
 
22
- // Drag & drop — accept image, PDF, and XML
23
- uploadArea.addEventListener('dragover', e => {
24
- e.preventDefault();
25
- uploadArea.classList.add('dragover');
26
- });
27
- uploadArea.addEventListener('dragleave', () => {
28
- uploadArea.classList.remove('dragover');
29
- });
30
- uploadArea.addEventListener('drop', e => {
31
- e.preventDefault();
32
- uploadArea.classList.remove('dragover');
33
- const files = Array.from(e.dataTransfer.files);
34
- const img = files.find(f =>
35
- f.type.startsWith('image/') || f.name.toLowerCase().endsWith('.pdf'));
36
- const xml = files.find(f => f.name.endsWith('.xml'));
37
- if (img) uploadFile(img);
38
- if (xml) uploadXml(xml); // queued after image upload sets imageId
 
 
 
 
39
  });
 
 
40
 
41
  // XML file picker
42
  xmlInput.addEventListener('change', () => {
 
6
 
7
  const $ = id => document.getElementById(id);
8
 
9
+ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.bmp', '.gif', '.webp']);
10
+
11
+ function extensionOf(file) {
12
+ const name = file?.name || '';
13
+ const dot = name.lastIndexOf('.');
14
+ return dot >= 0 ? name.slice(dot).toLowerCase() : '';
15
+ }
16
+
17
+ function isImageOrPdf(file) {
18
+ const ext = extensionOf(file);
19
+ return file.type.startsWith('image/') || ext === '.pdf' || IMAGE_EXTENSIONS.has(ext);
20
+ }
21
+
22
  export function initImageViewer() {
23
  const uploadArea = $('upload-area');
24
  const fileInput = $('file-input');
25
  const xmlInput = $('xml-input');
26
+ const viewerScroll = $('viewer-scroll');
27
+ const viewerPlaceholder = $('viewer-placeholder');
28
+
29
+ const handleDroppedFiles = files => {
30
+ const img = files.find(isImageOrPdf);
31
+ const xml = files.find(f => f.name.toLowerCase().endsWith('.xml'));
32
+ if (img) uploadFile(img);
33
+ if (xml) uploadXml(xml); // queued after image upload sets imageId
34
+ };
35
 
36
  // Click to browse image
37
  uploadArea.addEventListener('click', () => fileInput.click());
 
41
  if (fileInput.files.length > 0) uploadFile(fileInput.files[0]);
42
  });
43
 
44
+ // Drag & drop — accept image, PDF, and XML dropped on the upload box OR the viewer area.
45
+ // Users naturally drop on the large center viewer, not just the small upload box.
46
+ const dropTargets = [uploadArea, viewerScroll, viewerPlaceholder].filter(Boolean);
47
+ dropTargets.forEach(target => {
48
+ target.addEventListener('dragover', e => {
49
+ e.preventDefault();
50
+ uploadArea.classList.add('dragover');
51
+ if (viewerPlaceholder && !state.imageId) viewerPlaceholder.classList.add('dragover');
52
+ });
53
+ target.addEventListener('dragleave', e => {
54
+ if (!e.currentTarget.contains(e.relatedTarget)) {
55
+ uploadArea.classList.remove('dragover');
56
+ viewerPlaceholder?.classList.remove('dragover');
57
+ }
58
+ });
59
+ target.addEventListener('drop', e => {
60
+ e.preventDefault();
61
+ uploadArea.classList.remove('dragover');
62
+ viewerPlaceholder?.classList.remove('dragover');
63
+ handleDroppedFiles(Array.from(e.dataTransfer.files));
64
+ });
65
  });
66
+ // Keep upload-area explicitly compatible with batch-panel's capture-phase handler
67
+ uploadArea.addEventListener('drop', e => { e.preventDefault(); });
68
 
69
  // XML file picker
70
  xmlInput.addEventListener('change', () => {