From bfbe4eb04299515c3a4ca55c0c661e6d4a73fc1b Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 16:18:55 +0800 Subject: [PATCH 01/10] search support for od business --- components/SearchModal.tsx | 14 ++++++-------- pages/api/index.ts | 11 +++++++++++ pages/api/item.ts | 32 ++++++++++++++++++++++++++++++++ pages/api/search.ts | 21 ++++++++++++++++++--- 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 pages/api/item.ts diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 38aa27f..8c0f951 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -25,14 +25,12 @@ function useDriveItemSearch() { // Map parentReference to the absolute path of the search result data.map(item => { - // TODO: supporting sharepoint search where the path is not returned in parentReference - if ('path' in item.parentReference) { - item['path'] = `${mapAbsolutePath(item.parentReference.path)}/${encodeURIComponent(item.name)}` - } else { - throw Error( - 'We currently only support search in OneDrive international. SharePoint instances are not supported yet. See issue: https://github.com/spencerwooo/onedrive-vercel-index/issues/299' - ) - } + item['path'] = + 'path' in item.parentReference + ? // OneDrive International have the path returned in the parentReference field + `${mapAbsolutePath(item.parentReference.path)}/${encodeURIComponent(item.name)}` + : // OneDrive for Business/Education does not, so we need extra steps here + '' }) return data diff --git a/pages/api/index.ts b/pages/api/index.ts index 5547def..ef98722 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -12,6 +12,12 @@ import { getOdAuthTokens, storeOdAuthTokens } from '../../utils/odAuthTokenStore const basePath = pathPosix.resolve('/', siteConfig.baseDirectory) const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) +/** + * Encode the path of the file relative to the base directory + * + * @param path Relative path of the file to the base directory + * @returns Absolute path of the file inside OneDrive + */ export function encodePath(path: string): string { let encodedPath = pathPosix.join(basePath, pathPosix.resolve('/', path)) if (encodedPath === '/' || encodedPath === '') { @@ -21,6 +27,11 @@ export function encodePath(path: string): string { return `:${encodeURIComponent(encodedPath)}` } +/** + * Fetch the access token from Redis storage and check if the token requires a renew + * + * @returns Access token for OneDrive API + */ export async function getAccessToken(): Promise { const { accessToken, refreshToken } = await getOdAuthTokens() diff --git a/pages/api/item.ts b/pages/api/item.ts new file mode 100644 index 0000000..9ae9176 --- /dev/null +++ b/pages/api/item.ts @@ -0,0 +1,32 @@ +import axios from 'axios' +import type { NextApiRequest, NextApiResponse } from 'next' + +import { encodePath, getAccessToken } from '.' +import apiConfig from '../../config/api.json' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Get access token from storage + const accessToken = await getAccessToken() + + // Get item details (specifically, its path) by its unique ID in OneDrive + const { id = '' } = req.query + + if (typeof id === 'string') { + const itemApi = `${apiConfig.driveApi}/items/${id}` + + try { + const { data } = await axios.get(itemApi, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { + select: 'id,name,parentReference', + }, + }) + res.status(200).json(data) + } catch (error: any) { + res.status(error.response.status).json({ error: error.response.data }) + } + } else { + res.status(400).json({ error: 'Invalid driveItem ID.' }) + } + return +} diff --git a/pages/api/search.ts b/pages/api/search.ts index 1a9cff6..b626691 100644 --- a/pages/api/search.ts +++ b/pages/api/search.ts @@ -3,6 +3,18 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { encodePath, getAccessToken } from '.' import apiConfig from '../../config/api.json' +import siteConfig from '../../config/site.json' + +/** + * Sanitize the search query + * + * @param query User search query, which may contain special characters + * @returns Sanitised query string which replaces non-alphanumeric characters with ' ' + */ +function sanitiseQuery(query: string): string { + const sanitisedQuery = query.replace(/[^a-zA-Z0-9]/g, ' ') + return encodeURIComponent(sanitisedQuery) +} export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Get access token from storage @@ -12,15 +24,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { q: searchQuery = '' } = req.query if (typeof searchQuery === 'string') { - // Construct Microsoft Graph Search API URL, and perform search only under the base dir - const encodedPath = encodePath('/') === '' ? encodePath('/') : encodePath('/') + ':' - const searchApi = `${apiConfig.driveApi}/root${encodedPath}/search(q='${encodeURIComponent(searchQuery)}')` + // Construct Microsoft Graph Search API URL, and perform search only under the base directory + const searchRootPath = encodePath('/') + const encodedPath = searchRootPath === '' ? searchRootPath : searchRootPath + ':' + + const searchApi = `${apiConfig.driveApi}/root${encodedPath}/search(q='${sanitiseQuery(searchQuery)}')` try { const { data } = await axios.get(searchApi, { headers: { Authorization: `Bearer ${accessToken}` }, params: { select: 'id,name,file,folder,parentReference', + top: siteConfig.maxItems, }, }) res.status(200).json(data.value) From 0b55fe5fc796ed150d695bda7d0a302d1a7a9223 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 19:16:32 +0800 Subject: [PATCH 02/10] rename useAxiosFetch hook for universal fetch usage --- components/previews/CodePreview.tsx | 4 ++-- components/previews/MarkdownPreview.tsx | 4 ++-- components/previews/TextPreview.tsx | 4 ++-- components/previews/URLPreview.tsx | 4 ++-- utils/fetchOnMount.ts | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/components/previews/CodePreview.tsx b/components/previews/CodePreview.tsx index 98bd892..2e65a52 100644 --- a/components/previews/CodePreview.tsx +++ b/components/previews/CodePreview.tsx @@ -2,14 +2,14 @@ import { useEffect, FC } from 'react' import Prism from 'prismjs' import { getExtension } from '../../utils/getFileIcon' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' import { DownloadBtnContainer, PreviewContainer } from './Containers' const CodePreview: FC<{ file: any }> = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) useEffect(() => { if (typeof window !== 'undefined') { diff --git a/components/previews/MarkdownPreview.tsx b/components/previews/MarkdownPreview.tsx index 5e3b473..2bb8047 100644 --- a/components/previews/MarkdownPreview.tsx +++ b/components/previews/MarkdownPreview.tsx @@ -11,7 +11,7 @@ import 'katex/dist/katex.min.css' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ({ @@ -19,7 +19,7 @@ const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ( path, standalone = true, }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) // The parent folder of the markdown file, which is also the relative image folder const parentPath = path.substring(0, path.lastIndexOf('/')) diff --git a/components/previews/TextPreview.tsx b/components/previews/TextPreview.tsx index 83d44b5..70f15b5 100644 --- a/components/previews/TextPreview.tsx +++ b/components/previews/TextPreview.tsx @@ -1,11 +1,11 @@ import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadButtonGroup from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const TextPreview = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/components/previews/URLPreview.tsx b/components/previews/URLPreview.tsx index 461528c..6555747 100644 --- a/components/previews/URLPreview.tsx +++ b/components/previews/URLPreview.tsx @@ -1,7 +1,7 @@ import FourOhFour from '../FourOhFour' import Loading from '../Loading' import { DownloadButton } from '../DownloadBtnGtoup' -import useFileContent from '../../utils/fetchOnMount' +import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const parseDotUrl = (content: string): string | undefined => { @@ -12,7 +12,7 @@ const parseDotUrl = (content: string): string | undefined => { } const TextPreview = ({ file }) => { - const { content, error, validating } = useFileContent(file['@microsoft.graph.downloadUrl']) + const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index bc4ada2..116df31 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -2,19 +2,19 @@ import axios from 'axios' import { useEffect, useState } from 'react' // Custom hook to fetch raw file content on mount -export default function useFileContent(odRawUrl: string): { content: string; error: string; validating: boolean } { +export default function useAxiosGet(fetchUrl: string): { content: string; error: string; validating: boolean } { const [content, setContent] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') useEffect(() => { axios - .get(odRawUrl) + .get(fetchUrl) .then(res => setContent(res.data)) .catch(e => setError(e.message)) .finally(() => { setValidating(false) }) - }, [odRawUrl]) + }, [fetchUrl]) return { content, error, validating } } From 17263feea95eb862d0331897a86b65fd2c443cba Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:31:17 +0800 Subject: [PATCH 03/10] load item path on result mount --- components/SearchModal.tsx | 148 +++++++++++++++++++++++++++++-------- types/index.d.ts | 15 ++++ utils/fetchOnMount.ts | 2 +- 3 files changed, 132 insertions(+), 33 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 8c0f951..4983442 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -4,25 +4,39 @@ import { useAsync } from 'react-async-hook' import useConstant from 'use-constant' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { Dispatch, FC, Fragment, SetStateAction, useState } from 'react' +import { Dispatch, Fragment, SetStateAction, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' import Link from 'next/link' -import { OdSearchResult } from '../types' -import { getFileIcon } from '../utils/getFileIcon' -import siteConfig from '../config/site.json' +import { OdDriveItem, OdSearchResult } from '../types' import { LoadingIcon } from './Loading' +import { getFileIcon } from '../utils/getFileIcon' +import useAxiosGet from '../utils/fetchOnMount' +import siteConfig from '../config/site.json' + +/** + * Extract the searched item's path in field 'parentReference' and convert it to the + * absolute path represented in onedrive-vercel-index + * + * @param path Path returned from the parentReference field of the driveItem + * @returns The absolute path of the driveItem in the search result + */ +function mapAbsolutePath(path: string): string { + return path.split(siteConfig.baseDirectory === '/' ? 'root:' : siteConfig.baseDirectory)[1] +} + +/** + * Implements a debounced search function that returns a promise that resolves to an array of + * search results. + * + * @returns A react hook for a debounced async search of the drive + */ function useDriveItemSearch() { const [query, setQuery] = useState('') const searchDriveItem = async (q: string) => { const { data } = await axios.get(`/api/search?q=${q}`) - // Extract the searched item's path and convert it to the absolute path in onedrive-vercel-index - function mapAbsolutePath(path: string): string { - return siteConfig.baseDirectory === '/' ? path.split('root:')[1] : path.split(siteConfig.baseDirectory)[1] - } - // Map parentReference to the absolute path of the search result data.map(item => { item['path'] = @@ -36,12 +50,12 @@ function useDriveItemSearch() { return data } - const debouncedNotionSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000)) + const debouncedDriveItemSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000)) const results = useAsync(async () => { if (query.length === 0) { return [] } else { - return debouncedNotionSearch(query) + return debouncedDriveItemSearch(query) } }, [query]) @@ -52,7 +66,91 @@ function useDriveItemSearch() { } } -function SearchModal({ +function SearchResultItemTemplate({ + driveItem, + driveItemPath, + itemDescription, + disabled, +}: { + driveItem: OdSearchResult[number] + driveItemPath: string + itemDescription: string + disabled: boolean +}) { + return ( + + + +
+
{driveItem.name}
+
+ {itemDescription} +
+
+
+ + ) +} + +function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) { + const { + content, + error, + validating, + }: { + content: OdDriveItem + error: string + validating: boolean + } = useAxiosGet(`/api/item?id=${result.id}`) + + if (error) { + return + } + + if (validating) { + return ( + + ) + } + + const driveItemPath = `${mapAbsolutePath(content.parentReference.path)}/${encodeURIComponent(content.name)}` + return ( + + ) +} + +function SearchResultItem({ result }: { result: OdSearchResult[number] }) { + if (result.path === '') { + // path is empty, which means we need to fetch the parentReference to get the path + return + } else { + // path is not an empty string in the search result, such that we can directly render the component as is + const driveItemPath = decodeURIComponent(result.path) + return ( + + ) + } +} + +export default function SearchModal({ searchOpen, setSearchOpen, }: { @@ -102,10 +200,13 @@ function SearchModal({ value={query} onChange={e => setQuery(e.target.value)} /> -
ESC
+
ESC
-
+
{results.loading && (
@@ -120,22 +221,7 @@ function SearchModal({ {results.result.length === 0 ? (
Nothing here.
) : ( - results.result.map(result => ( - -
- -
-
{result.name}
-
- {decodeURIComponent(result.path)} -
-
-
- - )) + results.result.map(result => ) )} )} @@ -147,5 +233,3 @@ function SearchModal({ ) } - -export default SearchModal diff --git a/types/index.d.ts b/types/index.d.ts index f93f54d..d03c7c1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -32,6 +32,7 @@ export type OdFolderObject = { }> } +// Search result type which is returned by /api/search?q={query} export type OdSearchResult = Array<{ id: string name: string @@ -44,3 +45,17 @@ export type OdSearchResult = Array<{ path: string } }> + +// driveItem type which is returned by /api/item?id={id} +export type OdDriveItem = { + '@odata.context': string + '@odata.etag': string + id: string + name: string + parentReference: { + driveId: string + driveType: string + id: string + path: string + } +} diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index 116df31..05595d7 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -2,7 +2,7 @@ import axios from 'axios' import { useEffect, useState } from 'react' // Custom hook to fetch raw file content on mount -export default function useAxiosGet(fetchUrl: string): { content: string; error: string; validating: boolean } { +export default function useAxiosGet(fetchUrl: string): { content: any; error: string; validating: boolean } { const [content, setContent] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') From b47dd88d1a4b972e73b0379b2d82081125210007 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:50:43 +0800 Subject: [PATCH 04/10] useSWR instead of custom useAxiosGet hook for cache reuse --- components/SearchModal.tsx | 24 ++++++++---------------- components/previews/CodePreview.tsx | 2 +- components/previews/MarkdownPreview.tsx | 2 +- components/previews/TextPreview.tsx | 2 +- components/previews/URLPreview.tsx | 2 +- pages/api/item.ts | 2 +- utils/fetchOnMount.ts | 10 +++++----- 7 files changed, 18 insertions(+), 26 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 4983442..ab0a63f 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -1,19 +1,20 @@ import axios from 'axios' +import useSWR, { SWRResponse } from 'swr' +import { Dispatch, Fragment, SetStateAction, useState } from 'react' import AwesomeDebouncePromise from 'awesome-debounce-promise' import { useAsync } from 'react-async-hook' import useConstant from 'use-constant' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { Dispatch, Fragment, SetStateAction, useState } from 'react' -import { Dialog, Transition } from '@headlessui/react' import Link from 'next/link' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Dialog, Transition } from '@headlessui/react' import { OdDriveItem, OdSearchResult } from '../types' import { LoadingIcon } from './Loading' import { getFileIcon } from '../utils/getFileIcon' -import useAxiosGet from '../utils/fetchOnMount' import siteConfig from '../config/site.json' +import { fetcher } from '../utils/fetchWithSWR' /** * Extract the searched item's path in field 'parentReference' and convert it to the @@ -101,27 +102,18 @@ function SearchResultItemTemplate({ } function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) { - const { - content, - error, - validating, - }: { - content: OdDriveItem - error: string - validating: boolean - } = useAxiosGet(`/api/item?id=${result.id}`) + const { data, error }: SWRResponse = useSWR(`/api/item?id=${result.id}`, fetcher) if (error) { return } - - if (validating) { + if (!data) { return ( ) } - const driveItemPath = `${mapAbsolutePath(content.parentReference.path)}/${encodeURIComponent(content.name)}` + const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.name)}` return ( = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) useEffect(() => { if (typeof window !== 'undefined') { diff --git a/components/previews/MarkdownPreview.tsx b/components/previews/MarkdownPreview.tsx index 2bb8047..6a4209e 100644 --- a/components/previews/MarkdownPreview.tsx +++ b/components/previews/MarkdownPreview.tsx @@ -19,7 +19,7 @@ const MarkdownPreview: FC<{ file: any; path: string; standalone?: boolean }> = ( path, standalone = true, }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) // The parent folder of the markdown file, which is also the relative image folder const parentPath = path.substring(0, path.lastIndexOf('/')) diff --git a/components/previews/TextPreview.tsx b/components/previews/TextPreview.tsx index 70f15b5..15226a9 100644 --- a/components/previews/TextPreview.tsx +++ b/components/previews/TextPreview.tsx @@ -5,7 +5,7 @@ import useAxiosGet from '../../utils/fetchOnMount' import { DownloadBtnContainer, PreviewContainer } from './Containers' const TextPreview = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/components/previews/URLPreview.tsx b/components/previews/URLPreview.tsx index 6555747..5bf459f 100644 --- a/components/previews/URLPreview.tsx +++ b/components/previews/URLPreview.tsx @@ -12,7 +12,7 @@ const parseDotUrl = (content: string): string | undefined => { } const TextPreview = ({ file }) => { - const { content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) + const { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/pages/api/item.ts b/pages/api/item.ts index 9ae9176..8385c47 100644 --- a/pages/api/item.ts +++ b/pages/api/item.ts @@ -1,7 +1,7 @@ import axios from 'axios' import type { NextApiRequest, NextApiResponse } from 'next' -import { encodePath, getAccessToken } from '.' +import { getAccessToken } from '.' import apiConfig from '../../config/api.json' export default async function handler(req: NextApiRequest, res: NextApiResponse) { diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts index 05595d7..3a0a67f 100644 --- a/utils/fetchOnMount.ts +++ b/utils/fetchOnMount.ts @@ -1,20 +1,20 @@ import axios from 'axios' import { useEffect, useState } from 'react' -// Custom hook to fetch raw file content on mount -export default function useAxiosGet(fetchUrl: string): { content: any; error: string; validating: boolean } { - const [content, setContent] = useState('') +// Custom hook to axios get a URL or API endpoint on mount +export default function useAxiosGet(fetchUrl: string): { response: any; error: string; validating: boolean } { + const [response, setResponse] = useState('') const [validating, setValidating] = useState(true) const [error, setError] = useState('') useEffect(() => { axios .get(fetchUrl) - .then(res => setContent(res.data)) + .then(res => setResponse(res.data)) .catch(e => setError(e.message)) .finally(() => { setValidating(false) }) }, [fetchUrl]) - return { content, error, validating } + return { response, error, validating } } From 3476c652a396a80fbb135dfe4049bc88f7c5b1e3 Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 20:56:02 +0800 Subject: [PATCH 05/10] clear input box on search modal close --- components/SearchModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index ab0a63f..8054997 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -149,10 +149,13 @@ export default function SearchModal({ searchOpen: boolean setSearchOpen: Dispatch> }) { - const closeSearchBox = () => setSearchOpen(false) - const { query, setQuery, results } = useDriveItemSearch() + const closeSearchBox = () => { + setSearchOpen(false) + setQuery('') + } + return ( From 007e4126305e9b9a57496d99d5b42da53983f2d0 Mon Sep 17 00:00:00 2001 From: myl7 Date: Sun, 2 Jan 2022 11:54:31 +0800 Subject: [PATCH 06/10] handle errors when traversing --- components/MultiFileDownloader.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/components/MultiFileDownloader.tsx b/components/MultiFileDownloader.tsx index 1eb0bef..0fd1bd6 100644 --- a/components/MultiFileDownloader.tsx +++ b/components/MultiFileDownloader.tsx @@ -178,7 +178,18 @@ export async function* traverseFolder(path: string): AsyncGenerator< const itemLists = await Promise.all( folderPaths.map(fp => (async fp => { - const data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined) + let data: any + try { + data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined) + } catch (error: any) { + // Skip errors caused by the client + if (Math.floor(error.response.status / 100) === 4) { + return null + } else { + throw error + } + } + if (data && data.folder) { return data.folder.value.map((c: any) => { const p = `${fp === '/' ? '' : fp}/${encodeURIComponent(c.name)}` @@ -191,7 +202,7 @@ export async function* traverseFolder(path: string): AsyncGenerator< ) ) - const items = itemLists.flat() as { path: string; meta: any; isFolder: boolean }[] + const items = itemLists.filter(Boolean).flat() as { path: string; meta: any; isFolder: boolean }[] yield* items folderPaths = items.filter(i => i.isFolder).map(i => i.path) } From 9bde9bdab01b9bd6241e103812b3b0f1a9122759 Mon Sep 17 00:00:00 2001 From: myl7 Date: Mon, 24 Jan 2022 22:00:08 +0800 Subject: [PATCH 07/10] report handleable error in downloading --- components/FileListing.tsx | 6 +++++- components/MultiFileDownloader.tsx | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/components/FileListing.tsx b/components/FileListing.tsx index 8043f76..a947055 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -297,7 +297,11 @@ const FileListing: FC<{ query?: ParsedUrlQuery }> = ({ query }) => { // Folder recursive download const handleFolderDownload = (path: string, id: string, name?: string) => () => { const files = (async function* () { - for await (const { meta: c, path: p, isFolder } of traverseFolder(path)) { + for await (const { meta: c, path: p, isFolder, error } of traverseFolder(path)) { + if (error) { + toast.error(`Failed to download folder ${p}: ${error[0]} ${error[1]} Skipped it to continue.`) + continue + } yield { name: c?.name, url: c ? c['@microsoft.graph.downloadUrl'] : undefined, diff --git a/components/MultiFileDownloader.tsx b/components/MultiFileDownloader.tsx index 0fd1bd6..e08a96b 100644 --- a/components/MultiFileDownloader.tsx +++ b/components/MultiFileDownloader.tsx @@ -161,12 +161,14 @@ export async function downloadTreelikeMultipleFiles({ * @param path Folder to be traversed * @returns Array of items representing folders and files of traversed folder in BFS order and excluding root folder. * Due to BFS, folder items are ALWAYS in front of its children items. + * Error key in the item will contain the error when there is a handleable error. */ export async function* traverseFolder(path: string): AsyncGenerator< { path: string meta: any isFolder: boolean + error?: [number, string] }, void, undefined @@ -182,9 +184,14 @@ export async function* traverseFolder(path: string): AsyncGenerator< try { data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined) } catch (error: any) { - // Skip errors caused by the client - if (Math.floor(error.response.status / 100) === 4) { - return null + console.log(error) + // 4xx errors are identified as handleable errors + if (Math.floor(error.status / 100) === 4) { + return { + path: fp, + isFolder: true, + error: [error.status as number, error.message.error as string] + } } else { throw error } @@ -202,8 +209,11 @@ export async function* traverseFolder(path: string): AsyncGenerator< ) ) - const items = itemLists.filter(Boolean).flat() as { path: string; meta: any; isFolder: boolean }[] - yield* items - folderPaths = items.filter(i => i.isFolder).map(i => i.path) + const items = itemLists.flat() as { path: string; meta: any; isFolder: boolean; error?: [number, string] }[] + yield * items + folderPaths = items + .filter(({ error }) => !error) + .filter(i => i.isFolder) + .map(i => i.path) } } From 10b2b178cffada8b1d6c6f5a797f29fc4a1cb31c Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Mon, 24 Jan 2022 22:31:11 +0800 Subject: [PATCH 08/10] escape pound signs in path component --- components/SearchModal.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 8054997..e51fcbb 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -24,7 +24,12 @@ import { fetcher } from '../utils/fetchWithSWR' * @returns The absolute path of the driveItem in the search result */ function mapAbsolutePath(path: string): string { - return path.split(siteConfig.baseDirectory === '/' ? 'root:' : siteConfig.baseDirectory)[1] + // path is in the format of '/drive/root:/path/to/file', if baseDirectory is '/' then we split on 'root:', + // otherwise we split on the user defined 'baseDirectory' + const absolutePath = path.split(siteConfig.baseDirectory === '/' ? 'root:' : siteConfig.baseDirectory)[1] + // path returned by the API may contain #, by doing a decodeURIComponent and then encodeURIComponent we can + // replace URL sensitive characters such as the # with %23 + return encodeURIComponent(decodeURIComponent(absolutePath)) } /** @@ -134,7 +139,7 @@ function SearchResultItem({ result }: { result: OdSearchResult[number] }) { return ( From 6df1447d33244d58a86749288e051b3fbedaa6f9 Mon Sep 17 00:00:00 2001 From: myl7 Date: Mon, 24 Jan 2022 23:16:10 +0800 Subject: [PATCH 09/10] update error inner structure --- components/FileListing.tsx | 2 +- components/MultiFileDownloader.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/components/FileListing.tsx b/components/FileListing.tsx index a947055..f49c6b5 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -299,7 +299,7 @@ const FileListing: FC<{ query?: ParsedUrlQuery }> = ({ query }) => { const files = (async function* () { for await (const { meta: c, path: p, isFolder, error } of traverseFolder(path)) { if (error) { - toast.error(`Failed to download folder ${p}: ${error[0]} ${error[1]} Skipped it to continue.`) + toast.error(`Failed to download folder ${p}: ${error.status} ${error.message} Skipped it to continue.`) continue } yield { diff --git a/components/MultiFileDownloader.tsx b/components/MultiFileDownloader.tsx index e08a96b..b4f5b60 100644 --- a/components/MultiFileDownloader.tsx +++ b/components/MultiFileDownloader.tsx @@ -168,7 +168,7 @@ export async function* traverseFolder(path: string): AsyncGenerator< path: string meta: any isFolder: boolean - error?: [number, string] + error?: { status: number; message: string } }, void, undefined @@ -184,13 +184,12 @@ export async function* traverseFolder(path: string): AsyncGenerator< try { data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined) } catch (error: any) { - console.log(error) // 4xx errors are identified as handleable errors if (Math.floor(error.status / 100) === 4) { return { path: fp, isFolder: true, - error: [error.status as number, error.message.error as string] + error: { status: error.status, message: error.message.error }, } } else { throw error @@ -209,7 +208,12 @@ export async function* traverseFolder(path: string): AsyncGenerator< ) ) - const items = itemLists.flat() as { path: string; meta: any; isFolder: boolean; error?: [number, string] }[] + const items = itemLists.flat() as { + path: string + meta: any + isFolder: boolean + error?: { status: number; message: string } + }[] yield * items folderPaths = items .filter(({ error }) => !error) From b86893fed2065eaa6863e47568337bed52308af9 Mon Sep 17 00:00:00 2001 From: myl7 Date: Tue, 25 Jan 2022 00:51:42 +0800 Subject: [PATCH 10/10] clean path param earlier --- pages/api/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pages/api/index.ts b/pages/api/index.ts index ef98722..28f543f 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -19,7 +19,7 @@ const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) * @returns Absolute path of the file inside OneDrive */ export function encodePath(path: string): string { - let encodedPath = pathPosix.join(basePath, pathPosix.resolve('/', path)) + let encodedPath = pathPosix.join(basePath, path) if (encodedPath === '/' || encodedPath === '') { return '' } @@ -109,6 +109,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(400).json({ error: 'Path query invalid.' }) return } + const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path)) const accessToken = await getAccessToken() @@ -122,7 +123,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const protectedRoutes = siteConfig.protectedRoutes let authTokenPath = '' for (const r of protectedRoutes) { - if (path.startsWith(r)) { + if (cleanPath.startsWith(r)) { authTokenPath = `${r}/.password` break } @@ -161,7 +162,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - const requestPath = encodePath(path) + const requestPath = encodePath(cleanPath) // Handle response from OneDrive API const requestUrl = `${apiConfig.driveApi}/root${requestPath}` // Whether path is root, which requires some special treatment