diff --git a/applications/idp-arc/deploy/resources/default.conf b/applications/idp-arc/deploy/resources/default.conf index 3bc7e97..20c1c8e 100644 --- a/applications/idp-arc/deploy/resources/default.conf +++ b/applications/idp-arc/deploy/resources/default.conf @@ -1,9 +1,77 @@ +# When the frontend passes accessToken as a URL param (cross-domain cookie +# writes are blocked by browsers), Nginx injects it as a Cookie header in +# the forwarded request so chkclogin can read it. +# +# Two-case map: if the URL has accessToken, use only that cookie (browser has +# no JupyterHub cookies yet at this point anyway); otherwise forward the +# browser's existing cookies unchanged (for polling and upload requests). +# Extract the _xsrf cookie value so JavaScript can read it as X-XSRF-Token. +# The browser may carry multiple _xsrf cookies (hub token at /jupyter-proxy/hub/ +# and user-server token at /). The greedy .* captures the LAST occurrence, which +# is always the user-server's token — the one JupyterLab actually validates. +map $http_cookie $xsrf_val { + "~.*_xsrf=([^;]+)" $1; + default ""; +} + +map $arg_accessToken $proxy_cookie { + "" $http_cookie; + default "accessToken=$arg_accessToken"; +} + server { listen 8080; root /usr/share/nginx/html; index index.html; + # JupyterHub reverse proxy. + # Bridges the cross-domain cookie restriction: browser cannot set cookies for + # lab.v2dev.opensourcebrain.org from a different origin, but Nginx can inject + # them server-side. Set-Cookie domains are rewritten so session cookies are + # stored for this host and carried on subsequent polling / upload requests. + location /jupyter-proxy/ { + client_max_body_size 500m; + # Expose the _xsrf cookie value so JavaScript can use it as X-XSRFToken. + add_header X-XSRF-Token $xsrf_val always; + proxy_pass https://lab.v2dev.opensourcebrain.org/; + proxy_http_version 1.1; + + proxy_set_header Host lab.v2dev.opensourcebrain.org; + proxy_set_header Cookie $proxy_cookie; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_ssl_server_name on; + + # Rewrite Set-Cookie so the browser stores session cookies for this host. + proxy_cookie_domain lab.v2dev.opensourcebrain.org $host; + # Rewrite cookie Path so session cookies reach both /hub and /user endpoints. + # + # JupyterHub sets the auth session cookie with Path=/hub (hub.base_url). + # After prefixing to /jupyter-proxy/hub it would NOT be sent to + # /jupyter-proxy/user/… — causing 403 on the Contents API. + # Fix: expand any hub-scoped path to the full proxy root so the cookie is + # sent for ALL /jupyter-proxy/… requests. + # NOTE: hub cookies (incl. hub-side _xsrf) also reach /user/… this way; the + # Nginx map uses a greedy .* to select the LAST _xsrf in the Cookie header, + # which is always the user-server's token that JupyterLab validates against. + # + # Directive 1: /hub or /hub/… → /jupyter-proxy/ (hub cookies go everywhere) + proxy_cookie_path ~^/hub(/.*)?$ /jupyter-proxy/; + # Directive 2: everything else (user-server paths like /user/...) → Path=/ + # Mapping to root makes these cookies (including _xsrf set by JupyterLab) + # sent to all /jupyter-proxy/… requests (poll + upload). + proxy_cookie_path ~^/(?!hub|jupyter-proxy)(.*) /; + + # Rewrite Location redirect headers to keep subsequent requests in the proxy. + # Use explicit https://$host to avoid Nginx constructing an http://:8080 URL + # (which would be blocked as mixed content when the page is served over HTTPS). + proxy_redirect https://lab.v2dev.opensourcebrain.org/ https://$host/jupyter-proxy/; + proxy_redirect / https://$host/jupyter-proxy/; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/applications/idp-arc/frontend/src/app/container.ts b/applications/idp-arc/frontend/src/app/container.ts index 32379ed..8e0b183 100644 --- a/applications/idp-arc/frontend/src/app/container.ts +++ b/applications/idp-arc/frontend/src/app/container.ts @@ -30,7 +30,7 @@ const WWW_BASE = import.meta.env.DEV ? '/api-proxy' : `https://www.${BASE_ const WORKSPACES_API = `${WWW_BASE}/proxy/workspaces/api` const WORKSPACES_LIST_URL = `${WWW_BASE}/proxy/workspaces/api/workspace?page=1&per_page=24&q=&tags=` -const JUPYTER_BASE = `https://lab.${BASE_DOMAIN}` +const JUPYTER_BASE = '/jupyter-proxy' const FRONTEND_BASE = `https://www.${BASE_DOMAIN}` // ─── Infrastructure singletons ──────────────────────────────────────────────── diff --git a/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx new file mode 100644 index 0000000..5c96d44 --- /dev/null +++ b/applications/idp-arc/frontend/src/components/DataUploadDialog.tsx @@ -0,0 +1,387 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Box, + Button, + CircularProgress, + Dialog, + IconButton, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material' +import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlineOutlined' +import CloseIcon from '@mui/icons-material/Close' +import ArrowForwardIcon from '@mui/icons-material/ArrowForward' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' + +import { createAndUpload, loadWorkspaces } from '../app/container' +import { useAppContext } from '../AppContext' +import type { Workspace } from '../core/types' +import protocols from '../data/protocols.json' + +type DialogStep = 'select' | 'upload' | 'uploading' | 'success' + +export interface DataUploadDialogProps { + open: boolean + onClose: () => void +} + +export default function DataUploadDialog({ open, onClose }: DataUploadDialogProps) { + const { tokenParsed } = useAppContext() + + interface FormState { + step: DialogStep + behavioralTask: string + protocol: string + workspaceId: string | number + file: File | null + isDragging: boolean + uploadMessage: string + } + + const INITIAL_FORM: FormState = { + step: 'select', + behavioralTask: '', + protocol: '', + workspaceId: '', + file: null, + isDragging: false, + uploadMessage: '', + } + + const [form, setForm] = useState(INITIAL_FORM) + const { step, behavioralTask, protocol, workspaceId, file, isDragging, uploadMessage } = form + const [workspaces, setWorkspaces] = useState([]) + const [loadingWorkspaces, setLoadingWorkspaces] = useState(false) + const fileInputRef = useRef(null) + const abortRef = useRef(false) + + useEffect(() => { + if (!open) return + setForm(INITIAL_FORM) + abortRef.current = false + setLoadingWorkspaces(true) + loadWorkspaces() + .then(setWorkspaces) + .catch(console.error) + .finally(() => setLoadingWorkspaces(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + const dropped = e.dataTransfer.files[0] + setForm((prev) => ({ ...prev, isDragging: false, file: dropped ?? prev.file })) + }, []) + + const handleUpload = async () => { + if (!file) return + setForm((prev) => ({ ...prev, step: 'uploading' })) + abortRef.current = false + + const selectedWorkspace = workspaces.find(w => String(w.id) === String(workspaceId)) + const newWorkspaceName = [behavioralTask, protocol].filter(Boolean).join(' — ') || 'New Workspace' + const resolvedId = selectedWorkspace + ? (typeof selectedWorkspace.id === 'string' ? parseInt(selectedWorkspace.id, 10) : selectedWorkspace.id) + : undefined + + await createAndUpload( + { + workspaceName: selectedWorkspace?.name ?? newWorkspaceName, + workspaceId: resolvedId, + file, + userId: tokenParsed?.sub as string, + }, + (state) => { + setForm((prev) => ({ + ...prev, + uploadMessage: state.message, + ...(state.phase === 'done' ? { step: 'success' } : {}), + })) + }, + abortRef, + ) + } + + const stepIndex = step === 'select' ? 0 : 1 + const canGoNext = !!behavioralTask && !!protocol + const canUpload = !!file + + return ( + + {/* ── Header ─────────────────────────────────────────────────────── */} + + + + + {[0, 1].map((i) => ( + + ))} + + + + {tokenParsed && ( + + )} + + + + + + + {/* ── Title & subtitle ───────────────────────────────────────────── */} + {step === 'select' ? ( + + + Select behavioral task + + + Select a behavioral task and protocol. Optionally pick an existing workspace, or leave it empty to create a new one. + + + ) : ( + + + Upload your data + + {step === 'upload' && ( + + Upload your data to contribute to multimodal neurophysiology research and visualize it on the Open Source Brain platform. + + )} + + )} + + {/* ── Content ────────────────────────────────────────────────────── */} + + + {/* Step 1 — select */} + {step === 'select' && ( + + + + Behavioral task + + + + + Protocol + + + + + Open Source Brain workspace + + + + + + Protocol docs + + Download protocol docs based on your experiment type. + + + + + )} + + {/* Step 2 — file upload */} + {step === 'upload' && ( + + + setForm((prev) => ({ ...prev, file: e.target.files?.[0] ?? null }))} + /> + fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setForm((prev) => ({ ...prev, isDragging: true })) }} + onDragLeave={() => setForm((prev) => ({ ...prev, isDragging: false }))} + onDrop={handleDrop} + sx={{ + height: '100%', + border: '1px solid', + borderColor: isDragging ? 'primary.main' : 'divider', + borderRadius: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + bgcolor: isDragging ? 'action.hover' : 'transparent', + transition: 'border-color 0.2s, background-color 0.2s', + gap: 1, + }} + > + {file ? ( + + {file.name} ({(file.size / 1024).toFixed(1)} KB) + + ) : ( + <> + + + Click here or drag file to upload (.zip) + + + )} + + + + + Protocol template + + Download protocol template based on your experiment type. + + + + + )} + + {/* Loading */} + {step === 'uploading' && ( + + + {uploadMessage || 'Uploading your data..'} + + + )} + + {/* Success */} + {step === 'success' && ( + + + + Your files has been successfully uploaded to Open Source Brain. + + + You can close this dialog. + + + )} + + + {/* ── Footer ─────────────────────────────────────────────────────── */} + {step === 'select' && ( + + + + )} + {step === 'upload' && ( + + + + )} + + ) +} diff --git a/applications/idp-arc/frontend/src/components/PageLayout.tsx b/applications/idp-arc/frontend/src/components/PageLayout.tsx index 78dcc42..05b188c 100644 --- a/applications/idp-arc/frontend/src/components/PageLayout.tsx +++ b/applications/idp-arc/frontend/src/components/PageLayout.tsx @@ -28,6 +28,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { authClient } from '../app/container' import { useAppContext } from '../AppContext' import { Logo } from '../Icons' +import DataUploadDialog from './DataUploadDialog' const ArrowIcon = () => @@ -54,6 +55,7 @@ export default function PageLayout({ const avatarMenuOpen = Boolean(avatarAnchor) const [waitingForLogin, setWaitingForLogin] = useState(false) const popupRef = useRef(null) + const [uploadDialogOpen, setUploadDialogOpen] = useState(false) async function handleLogin() { const loginUrl = await authClient.getLoginUrl(`${window.location.origin}/`) @@ -268,7 +270,7 @@ export default function PageLayout({ @@ -367,8 +369,8 @@ export default function PageLayout({ variant="contained" endIcon={} onClick={() => { - navigate('/workspaces') setDrawerOpen(false) + setUploadDialogOpen(true) }} > {t('nav.dataUpload')} @@ -446,6 +448,8 @@ export default function PageLayout({ + setUploadDialogOpen(false)} /> + {t('nav.signingIn')} diff --git a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts b/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts index 164705a..8cf2cb7 100644 --- a/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts +++ b/applications/idp-arc/frontend/src/core/ports/IJupyterApi.ts @@ -12,7 +12,7 @@ export interface IJupyterApi { * @param userId Subject claim from the token (used in hub URL). * @param serverName Named-server identifier (e.g. `"42lab"`). */ - triggerSpawn(token: string, userId: string, serverName: string): void + triggerSpawn(token: string, userId: string, serverName: string): Promise /** * Sets the session cookie required by JupyterHub and fires the spawn trigger. diff --git a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts index d0da2a7..cc4528f 100644 --- a/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts +++ b/applications/idp-arc/frontend/src/core/use-cases/createAndUploadWorkspace.ts @@ -12,6 +12,8 @@ export interface CreateAndUploadInput { file: File /** Subject claim (`sub`) from the token payload — identifies the JupyterHub user. */ userId: string + /** When provided the workspace creation step is skipped and the file is uploaded to this workspace. */ + workspaceId?: number /** * Called synchronously the moment the workspace id is known (right after * Step 1, before the long PVC wait). Use this to open the workspace tab @@ -49,15 +51,17 @@ export function createCreateAndUploadUseCase( const { workspaceName, file, userId, onWorkspaceCreated } = input try { - // ── Step 1: Create workspace ────────────────────────────────────────── - onProgress({ phase: 'creating', message: PHASE_LABELS.creating }) const token = await auth.getToken(30) - const wsId = await workspaceApi.createWorkspace(token, workspaceName) - // Notify the caller immediately — this fires before the long PVC wait - // so it is still within the browser's user-gesture async chain, which - // means window.open() passed here will NOT be blocked as a popup. - onWorkspaceCreated?.(wsId) + // ── Step 1: Create workspace (skipped when uploading to an existing one) ─ + let wsId: number + if (input.workspaceId) { + wsId = input.workspaceId + } else { + onProgress({ phase: 'creating', message: PHASE_LABELS.creating }) + wsId = await workspaceApi.createWorkspace(token, workspaceName) + onWorkspaceCreated?.(wsId) + } if (abortRef.current) return null @@ -66,7 +70,7 @@ export function createCreateAndUploadUseCase( onProgress({ phase: 'spawning', message: PHASE_LABELS.spawning, workspaceId: wsId }) const spawnToken = await auth.getToken(30) - jupyterApi.triggerSpawn(spawnToken, userId, serverName) + await jupyterApi.triggerSpawn(spawnToken, userId, serverName) // Wait 30 s for the PVC to initialise before polling for (let i = 30; i > 0; i--) { diff --git a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts index 7dacf9f..f2bd18b 100644 --- a/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/jupyterApiClient.ts @@ -13,19 +13,33 @@ import type { IJupyterApi } from '../core/ports/IJupyterApi' * Use-cases only see IJupyterApi — they have no idea these browser APIs exist. */ export class JupyterApiClient implements IJupyterApi { + // Captured from the first successful contents probe; Nginx echoes the _xsrf + // cookie value as X-XSRF-Token so we can read it even when the cookie path + // makes it inaccessible via document.cookie. + private xsrfToken = '' + constructor( - private readonly jupyterBase: string, // e.g. "https://lab.v2dev.opensourcebrain.org" + private readonly jupyterBase: string, // e.g. "/jupyter-proxy" private readonly baseDomain: string, // e.g. "v2dev.opensourcebrain.org" ) {} - triggerSpawn(token: string, userId: string, serverName: string): void { + async triggerSpawn(token: string, userId: string, serverName: string): Promise { // Set the session cookie so JupyterHub can authenticate the user document.cookie = `accessToken=${token};path=/;domain=.${this.baseDomain};SameSite=Lax;Secure` - // Fire-and-forget — no-cors is intentional, we only need to trigger auth + spawn - void fetch( - `${this.jupyterBase}/hub/chlogin?next=%2Fhub%2Fspawn%2F${userId}%2F${serverName}`, - { credentials: 'include', mode: 'no-cors' }, - ) + // Step 1: chkclogin — Nginx injects accessToken as Cookie header so JupyterHub + // validates it and sets the jupyterhub-hub-login session cookie. + // redirect:'manual' stops at the 302; the browser still stores Set-Cookie from it. + await fetch( + `${this.jupyterBase}/hub/chkclogin?accessToken=${encodeURIComponent(token)}`, + { credentials: 'include', redirect: 'manual' }, + ).catch(() => {}) + + // Step 2: Spawn the named server. redirect:'manual' stops before the + // spawn-pending polling loop; waitUntilReady handles readiness independently. + await fetch( + `${this.jupyterBase}/hub/spawn/${userId}/${serverName}`, + { credentials: 'include', redirect: 'manual' }, + ).catch(() => {}) } async waitUntilReady( @@ -35,15 +49,32 @@ export class JupyterApiClient implements IJupyterApi { abortRef: { current: boolean }, ): Promise { const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/` + // Non-API URL used to complete the per-server OAuth dance when the server is up + // but the per-server cookie hasn't been established yet. + const serverUrl = `${this.jupyterBase}/user/${userId}/${serverName}/` while (!abortRef.current && Date.now() < deadlineMs) { try { - const probe = await fetch(contentsUrl, { credentials: 'include' }) - if (probe.ok) return true - // Any non-502/503 response means the server replied and is not in a transient retry state — stop waiting - if (probe.status !== 503 && probe.status !== 502) break + const probe = await fetch(contentsUrl, { + credentials: 'include', + redirect: 'error', // treat auth redirects as "not ready" rather than looping + }) + if (probe.ok) { + this.xsrfToken = probe.headers.get('X-XSRF-Token') ?? getXsrfToken() + return true + } + if (probe.status === 403) { + // Server is running but the per-server OAuth cookie is missing. + // Fetch the HTML endpoint with redirect:'follow': Nginx proxy_redirect rewrites + // all OAuth redirects back through our proxy, so the per-server + // jupyterhub-user-{name}-{server} cookie is stored for our host. + await fetch(serverUrl, { credentials: 'include', redirect: 'follow' }).catch(() => {}) + } else if (probe.status !== 503 && probe.status !== 502 && probe.status !== 404) { + // Definitive non-retryable failure + break + } } catch { - // Network / CORS error — keep retrying + // Network / CORS error or redirect — keep retrying } await sleep(4_000) } @@ -52,13 +83,28 @@ export class JupyterApiClient implements IJupyterApi { } async uploadFile(userId: string, serverName: string, file: File): Promise { + // Diagnostic: verify JupyterLab is reachable immediately before upload + const contentsUrl = `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/` + try { + const probe = await fetch(contentsUrl, { credentials: 'include', redirect: 'error' }) + console.log('[upload-probe] status:', probe.status, 'ok:', probe.ok) + console.log('[upload-probe] X-XSRF-Token header:', probe.headers.get('X-XSRF-Token')) + console.log('[upload-probe] xsrfToken in memory:', this.xsrfToken) + console.log('[upload-probe] document.cookie _xsrf:', document.cookie.match(/(?:^|;)\s*_xsrf=([^;]*)/)?.[0] ?? '(not found)') + } catch (e) { + console.log('[upload-probe] fetch threw (redirect or network):', e) + } + const content = await readFileAsBase64(file) const res = await fetch( `${this.jupyterBase}/user/${userId}/${serverName}/api/contents/${encodeURIComponent(file.name)}`, { method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-XSRFToken': this.xsrfToken || getXsrfToken(), + }, body: JSON.stringify({ name: file.name, path: file.name, @@ -74,6 +120,11 @@ export class JupyterApiClient implements IJupyterApi { } } +function getXsrfToken(): string { + const match = document.cookie.match(/(?:^|;)\s*_xsrf=([^;]+)/) + return match ? decodeURIComponent(match[1]) : '' +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts b/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts index 83319af..546d154 100644 --- a/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts +++ b/applications/idp-arc/frontend/src/infra/mocks/mockJupyterApiClient.ts @@ -8,7 +8,7 @@ import type { IJupyterApi } from '../../core/ports/IJupyterApi' * • uploadFile() logs and resolves immediately */ export class MockJupyterApiClient implements IJupyterApi { - triggerSpawn(_token: string, userId: string, serverName: string): void { + async triggerSpawn(_token: string, userId: string, serverName: string): Promise { console.info( `[MockJupyterApiClient] triggerSpawn(userId="${userId}", serverName="${serverName}")`, )