Skip to content

Add programmatic cross-origin API for external web apps#409

Open
krokicki wants to merge 6 commits into
mainfrom
programmatic-api
Open

Add programmatic cross-origin API for external web apps#409
krokicki wants to merge 6 commits into
mainfrom
programmatic-api

Conversation

@krokicki

@krokicki krokicki commented Jul 17, 2026

Copy link
Copy Markdown
Member

What this adds

A programmatic HTTP API that lets another same-site web app (e.g. ai-cryoet.int.janelia.org) read and write files through Fileglancer as the logged-in user, without implementing its own login. When an operation needs authentication, a small Fileglancer popup runs the normal Okta/simple login and closes itself; if the user already has a session it's instant (or invisible via a hidden iframe).

The motivating flow: a user clicks "Save" in the other app → the app calls Fileglancer's API → if there's no session yet, a login popup appears → the file is written.

How it works

Full write-up is in docs/ProgrammaticAPI.md (architecture, config, endpoint list, connect flow, optimistic concurrency, security) and clients/js/README.md (SDK usage). The short version:

Because the apps share a registrable domain (janelia.org), the browser attaches Fileglancer's SameSite=Lax session cookie to cross-subdomain requests automatically — so the cookie is the API credential and the other app handles no tokens. Access is gated server-side by an explicit origin allowlist rather than by the cookie alone.

What's included

Backend

  • api_allowed_origins setting — the cross-site boundary. get_current_user now rejects (403) any request whose Origin is neither same-origin nor allowlisted. This also closes a latent exposure where wildcard CORS + the SameSite=Lax cookie let any *.janelia.org page ride a logged-in session; the anonymous /files/{sharing_key} data links are unaffected.
  • GET /api/auth/allowed-origins so the connect popup can self-check its origin.
  • PUT /api/content/{fsp}?subpath=... streams the request body to disk as the user (new write_file worker action + an _fd_mode flag so the passed fd is writable). Works under NFS root squash because the fd is opened by the setuid'd worker, so permission is checked at open(), not on the parent's writes. This is the first content-write path in the API — previously only empty files could be created.
  • Optimistic concurrency: GET/HEAD /api/content emit a strong ETag (from mtime+size) and Last-Modified; PUT honors If-Match (incl. *) and If-Unmodified-Since, returning 412 without altering the file on mismatch (the worker validates against the opened fd before truncating, so it's race-tight).
  • Upload cap: max_upload_size_bytes (default 50 GiB, 0 disables) — rejects up front on an over-limit Content-Length and re-checks byte count mid-stream for chunked clients, returning 413.

Frontend

  • /connect-complete bare route that posts { authenticated, username } to the requesting origin (validated against the allowlist) via postMessage, then closes (popup) or reports back (silent hidden iframe) so the client escalates to a visible popup only when login is actually required.
  • Login screen cleanup: removed the Help & Documentation box, centered the title and login box, and updated the subtitle. When login runs inside the connect popup it renders bare (no navbar chrome) and with halved spacing so the text doesn't wrap and overflow the small window.

Client SDK

  • clients/js@fileglancer/client: credentialed requests, the popup/iframe connect handshake, retry-on-401, typed list/read/write/mkdir/rename/delete, writeFile(..., { ifMatch, ifUnmodifiedSince }) for optimistic concurrency, and AuthRequiredError / ForbiddenError / ConflictError.

Docs & tests

  • docs/ProgrammaticAPI.md, config-template entries, and the SDK README.
  • Origin enforcement (tests/test_api_auth.py); write, optimistic-concurrency, and upload-limit tests (tests/test_endpoints.py).

Deployment / config

api_allowed_origins:
  - https://ai-cryoet.int.janelia.org
  - https://nextflow.int.janelia.org:8444   # dev
# max_upload_size_bytes: 53687091200          # optional; default 50 GiB, 0 disables

Same-origin (the Fileglancer UI) is always allowed and need not be listed.

Testing

  • Backend: pixi run -e test test-backend — 754 passed.
  • Frontend: pixi run node-check clean for the new files; ESLint/Prettier applied.
  • SDK: tsc --noEmit clean.

Not included / follow-ups

  • The SDK isn't wired into a build/CI or published yet — it's a standalone package under clients/js that typechecks on its own; consuming apps build it locally or vendor src/index.ts for now.
  • ETag ceiling: it's mtime+size, so two same-size writes within the filesystem's mtime resolution (coarse on some NFS) can share an ETag — fine for interactive save-conflict detection, not locking.
  • Chunked upload aborted at the size cap leaves a partial file bounded by the limit (the up-front Content-Length check avoids this for honest clients); unlink-on-abort skipped for now.
  • SameSite=Lax and the wildcard CORS are intentionally unchanged — the approach depends on same-site deployment. If an integrator ever lands off janelia.org, the SDK's private request() method is the single seam where a bearer-token transport would slot in.

@StephanPreibisch @JaneliaSciComp/fileglancer

Lets another same-site web app (e.g. ai-cryoet.int.janelia.org) read and
write files through Fileglancer as the logged-in user, with no login of its
own. The SameSite=Lax session cookie is attached automatically on same-site
cross-subdomain requests, so the cookie is the API credential; a popup/iframe
handshake handles the login-when-needed case.

Backend:
- api_allowed_origins setting: the cross-site boundary. get_current_user now
  rejects (403) any Origin that is neither same-origin nor allowlisted, closing
  a latent hole where the wildcard CORS + Lax cookie let any same-site page ride
  a logged-in session. Anonymous /files/ data links are unaffected.
- GET /api/auth/allowed-origins so the connect popup can self-check its origin.
- PUT /api/content/{fsp}?subpath=... streams the request body to disk as the
  user (new write_file worker action + _fd_mode so the passed fd is writable).
  Works under NFS root squash: the fd is opened by the setuid'd worker, so
  permission is checked at open() time, not on the parent's writes.

Frontend:
- /connect-complete bare route: posts { authenticated, username } to the
  requesting origin (validated against the allowlist) via postMessage, then
  closes (popup) or reports back (silent iframe) so the SDK can escalate.

Client:
- clients/js @fileglancer/client: credentialed requests, popup/iframe connect
  handshake, retry-on-401, typed list/read/write/mkdir/rename/delete.

Docs & tests:
- docs/ProgrammaticAPI.md, config template entry, SDK README.
- Unit + integration tests for origin enforcement; write-endpoint tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
krokicki and others added 5 commits July 17, 2026 17:24
GET/HEAD /api/content now emit a strong ETag (mtime+size) and Last-Modified.
PUT /api/content honors If-Match (incl. "*") and If-Unmodified-Since,
returning 412 without altering the file on mismatch. The write_file worker
opens write-only WITHOUT truncate, validates the precondition against the
real fd, and ftruncates only once it passes — so a failed check leaves the
file untouched and the check is race-tight rather than check-then-clobber.

- make_etag / parse_http_date_to_epoch helpers (utils).
- open_file returns last_modified so GET can build the ETag consistently
  with HEAD and the PUT precondition check (all from the same stat fields).
- SDK: writeFile(..., {ifMatch, ifUnmodifiedSince}) + ConflictError (412);
  reads already expose the ETag on the returned Response.
- Docs: concurrency sections in ProgrammaticAPI.md and the SDK README.

ETag ceiling: mtime+size, so two same-size writes within the filesystem's
mtime resolution can share an ETag — fine for interactive save-conflict
detection, not locking.

Also drops 3 stray assertions in test_put_file_content_path_traversal_blocked
that only passed because the traversal error text contains "directory".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guards the streaming write path against an unbounded upload filling the disk.
Two layers: reject up front when a declared Content-Length exceeds the limit
(so the file is never opened/truncated for an obviously-too-large upload), and
re-check byte count inside the streaming loop for chunked or dishonest clients,
raising 413 and terminating. Default limit 50 GiB; 0 disables.

Cleanup simplified to a single finally (closes the handle on success, 413,
disconnect, or write error); real write errors now flow through the app's
global exception handler instead of a local logger.exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove Help & Documentation box, center title and login box, update
subtitle. Hide navbar chrome and halve spacing when login is shown in
the external-app connect popup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant