From c19b83c5daffc98dbd90c6b02ef9de3d5a02f28d Mon Sep 17 00:00:00 2001 From: Jeremy Magland Date: Wed, 27 Sep 2023 15:01:15 -0400 Subject: [PATCH] support embargoed dandisets --- gui/src/ApiKeysWindow/ApiKeysWindow.tsx | 73 +++++++++++++++++++ gui/src/ApplicationBar.tsx | 21 +++++- gui/src/pages/NwbPage/NwbPage.tsx | 55 ++++++++++++-- .../NwbPage/getAuthorizationHeaderForUrl.ts | 14 ++++ 4 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 gui/src/ApiKeysWindow/ApiKeysWindow.tsx create mode 100644 gui/src/pages/NwbPage/getAuthorizationHeaderForUrl.ts diff --git a/gui/src/ApiKeysWindow/ApiKeysWindow.tsx b/gui/src/ApiKeysWindow/ApiKeysWindow.tsx new file mode 100644 index 00000000..8ff64ecc --- /dev/null +++ b/gui/src/ApiKeysWindow/ApiKeysWindow.tsx @@ -0,0 +1,73 @@ +import { FunctionComponent, useCallback, useEffect, useReducer } from "react" + +type ApiKeysWindowProps = { + onClose: () => void +} + +type KeysState = { + dandiApiKey: string + dandiStagingApiKey: string +} + +const defaultKeysState: KeysState = { + dandiApiKey: '', + dandiStagingApiKey: '' +} + +type KeysAction = { + type: 'setDandiApiKey' | 'setDandiStagingApiKey' + value: string +} + +const keysReducer = (state: KeysState, action: KeysAction): KeysState => { + switch (action.type) { + case 'setDandiApiKey': + return {...state, dandiApiKey: action.value} + case 'setDandiStagingApiKey': + return {...state, dandiStagingApiKey: action.value} + default: + throw new Error('invalid action type') + } +} + +const ApiKeysWindow: FunctionComponent = ({onClose}) => { + const [keys, keysDispatch] = useReducer(keysReducer, defaultKeysState) + useEffect(() => { + // initialize from local storage + const dandiApiKey = localStorage.getItem('dandiApiKey') || '' + const dandiStagingApiKey = localStorage.getItem('dandiStagingApiKey') || '' + keysDispatch({type: 'setDandiApiKey', value: dandiApiKey}) + keysDispatch({type: 'setDandiStagingApiKey', value: dandiStagingApiKey}) + }, []) + const handleSave = useCallback(() => { + localStorage.setItem('dandiApiKey', keys.dandiApiKey) + localStorage.setItem('dandiStagingApiKey', keys.dandiStagingApiKey) + onClose() + }, [keys, onClose]) + return ( +
+

Set API Keys

+
+ + + + + + + + + + + +
DANDI API Key: keysDispatch({type: 'setDandiApiKey', value: e.target.value})} />
DANDI Staging API Key: keysDispatch({type: 'setDandiStagingApiKey', value: e.target.value})} />
+
+
+ +   + +
+
+ ) +} + +export default ApiKeysWindow \ No newline at end of file diff --git a/gui/src/ApplicationBar.tsx b/gui/src/ApplicationBar.tsx index 5982064b..18cac91c 100644 --- a/gui/src/ApplicationBar.tsx +++ b/gui/src/ApplicationBar.tsx @@ -1,4 +1,4 @@ -import { Login, Logout } from "@mui/icons-material"; +import { Key, Login, Logout } from "@mui/icons-material"; import { AppBar, Toolbar } from "@mui/material"; import { FunctionComponent, useCallback, useMemo, useState } from "react"; import Hyperlink from "./components/Hyperlink"; @@ -7,6 +7,8 @@ import GitHubLoginWindow from "./GitHub/GitHubLoginWindow"; import { useGithubAuth } from "./GithubAuth/useGithubAuth"; import UserIdComponent from "./UserIdComponent"; import useRoute from "./useRoute"; +import SmallIconButton from "./components/SmallIconButton"; +import ApiKeysWindow from "./ApiKeysWindow/ApiKeysWindow"; type Props = { // none @@ -30,6 +32,7 @@ const ApplicationBar: FunctionComponent = () => { }, [setRoute]) const {visible: githubAccessWindowVisible, handleOpen: openGitHubAccessWindow, handleClose: closeGitHubAccessWindow} = useModalDialog() + const {visible: apiKeysWindowVisible, handleOpen: openApiKeysWindow, handleClose: closeApiKeysWindow} = useModalDialog() // light greenish background color for app bar // const barColor = '#e0ffe0' @@ -50,6 +53,14 @@ const ApplicationBar: FunctionComponent = () => {
   Neurosift
      {star} This viewer is in alpha and is under active development {star}
+ + } + onClick={openApiKeysWindow} + title={`Set DANDI API key`} + /> + +    { signedIn && (    @@ -80,6 +91,14 @@ const ApplicationBar: FunctionComponent = () => { onClose={() => closeGitHubAccessWindow()} /> + + closeApiKeysWindow()} + /> + ) } diff --git a/gui/src/pages/NwbPage/NwbPage.tsx b/gui/src/pages/NwbPage/NwbPage.tsx index aa5fbc7b..67b3e433 100644 --- a/gui/src/pages/NwbPage/NwbPage.tsx +++ b/gui/src/pages/NwbPage/NwbPage.tsx @@ -8,6 +8,7 @@ import NwbTabWidget from "./NwbTabWidget" import { getRemoteH5File, globalRemoteH5FileStats, RemoteH5File } from "./RemoteH5File/RemoteH5File" import { SelectedItemViewsContext, selectedItemViewsReducer } from "./SelectedItemViewsContext" import { fetchJson } from "./viewPlugins/ImageSeries/ImageSeriesItemView" +import getAuthorizationHeaderForUrl from "./getAuthorizationHeaderForUrl" type Props = { width: number @@ -74,16 +75,17 @@ const NwbPageChild: FunctionComponent = ({width, height}) => { useEffect(() => { let canceled = false const load = async () => { - const metaUrl = await getMetaUrl(url || defaultUrl) + const urlResolved = await getResolvedUrl(url || defaultUrl) + const metaUrl = await getMetaUrl(urlResolved) if (canceled) return - if ((!metaUrl) && (url) && (rtcshareClient)) { - console.info(`Requesting meta for ${url}`) + if ((!metaUrl) && (urlResolved) && (rtcshareClient)) { + console.info(`Requesting meta for ${urlResolved}`) rtcshareClient.serviceQuery('neurosift-nwb-request', { type: 'request-meta-nwb', - nwbUrl: url + nwbUrl: urlResolved }) } - const f = await getRemoteH5File(url || defaultUrl, metaUrl) + const f = await getRemoteH5File(urlResolved, metaUrl) if (canceled) return setNwbFile(f) } @@ -105,7 +107,7 @@ const NwbPageChild: FunctionComponent = ({width, height}) => { ) } -export const headRequest = async (url: string) => { +export const headRequest = async (url: string, headers?: any) => { // Cannot use HEAD, because it is not allowed by CORS on DANDI AWS bucket // let headResponse // try { @@ -123,7 +125,10 @@ export const headRequest = async (url: string) => { // Instead, use aborted GET. const controller = new AbortController(); const signal = controller.signal; - const response = await fetch(url, { signal }) + const response = await fetch(url, { + signal, + headers + }) controller.abort(); return response } @@ -183,4 +188,40 @@ const getMetaUrl = async (url: string) => { // return undefined } +const getResolvedUrl = async (url: string) => { + if (isDandiAssetUrl(url)) { + const authorizationHeader = getAuthorizationHeaderForUrl(url) + const headers = authorizationHeader ? {Authorization: authorizationHeader} : undefined + const redirectUrl = await getRedirectUrl(url, headers) + if (redirectUrl) { + return redirectUrl + } + } + return url +} + +const getRedirectUrl = async (url: string, headers: any) => { + // This is tricky. Normally we would do a HEAD request with a redirect: 'manual' option. + // and then look at the Location response header. + // However, we run into mysterious cors problems + // So instead, we do a HEAD request with no redirect option, and then look at the response.url + const response = await headRequest(url, headers) + if (response.url) return response.url + + // if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + // return response.headers.get('Location') + // } + + return null // No redirect + } + +const isDandiAssetUrl = (url: string) => { + if (url.startsWith('https://api-staging.dandiarchive.org/')) { + return true + } + if (url.startsWith('https://api.dandiarchive.org/')) { + return true + } +} + export default NwbPage \ No newline at end of file diff --git a/gui/src/pages/NwbPage/getAuthorizationHeaderForUrl.ts b/gui/src/pages/NwbPage/getAuthorizationHeaderForUrl.ts new file mode 100644 index 00000000..1d50b4d4 --- /dev/null +++ b/gui/src/pages/NwbPage/getAuthorizationHeaderForUrl.ts @@ -0,0 +1,14 @@ +const getAuthorizationHeaderForUrl = (url?: string) => { + if (!url) return '' + let key = '' + if (url.startsWith('https://api-staging.dandiarchive.org/')) { + key = localStorage.getItem('dandiStagingApiKey') || '' + } + else if (url.startsWith('https://api.dandiarchive.org/')) { + key = localStorage.getItem('dandiApiKey') || '' + } + if (key) return 'token ' + key + else return '' +} + +export default getAuthorizationHeaderForUrl \ No newline at end of file