diff --git a/components/FileListing.tsx b/components/FileListing.tsx index 9307e8e..71ce95c 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -295,7 +295,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.status} ${error.message} 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 1eb0bef..b4f5b60 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?: { status: number; message: string } }, void, undefined @@ -178,7 +180,22 @@ 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) { + // 4xx errors are identified as handleable errors + if (Math.floor(error.status / 100) === 4) { + return { + path: fp, + isFolder: true, + error: { status: error.status, message: error.message.error }, + } + } else { + throw error + } + } + if (data && data.folder) { return data.folder.value.map((c: any) => { const p = `${fp === '/' ? '' : fp}/${encodeURIComponent(c.name)}` @@ -191,8 +208,16 @@ export async function* traverseFolder(path: string): AsyncGenerator< ) ) - const items = itemLists.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?: { status: number; message: string } + }[] + yield * items + folderPaths = items + .filter(({ error }) => !error) + .filter(i => i.isFolder) + .map(i => i.path) } } diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index 38aa27f..e51fcbb 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -1,49 +1,67 @@ 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, FC, 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 { 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 siteConfig from '../config/site.json' +import { fetcher } from '../utils/fetchWithSWR' + +/** + * 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 { + // 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)) +} + +/** + * 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 => { - // 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 } - 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]) @@ -54,17 +72,95 @@ 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 { data, error }: SWRResponse = useSWR(`/api/item?id=${result.id}`, fetcher) + + if (error) { + return + } + if (!data) { + return ( + + ) + } + + const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.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, }: { searchOpen: boolean setSearchOpen: Dispatch> }) { - const closeSearchBox = () => setSearchOpen(false) - const { query, setQuery, results } = useDriveItemSearch() + const closeSearchBox = () => { + setSearchOpen(false) + setQuery('') + } + return ( @@ -104,10 +200,13 @@ function SearchModal({ value={query} onChange={e => setQuery(e.target.value)} /> -
ESC
+
ESC
-
+
{results.loading && (
@@ -122,22 +221,7 @@ function SearchModal({ {results.result.length === 0 ? (
Nothing here.
) : ( - results.result.map(result => ( - -
- -
-
{result.name}
-
- {decodeURIComponent(result.path)} -
-
-
- - )) + results.result.map(result => ) )} )} @@ -149,5 +233,3 @@ function SearchModal({ ) } - -export default SearchModal diff --git a/components/previews/CodePreview.tsx b/components/previews/CodePreview.tsx index 98bd892..e3497a9 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 { 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 5e3b473..6a4209e 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 { 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 83d44b5..15226a9 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 { 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 461528c..5bf459f 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 { response: content, error, validating } = useAxiosGet(file['@microsoft.graph.downloadUrl']) if (error) { return ( diff --git a/pages/api/index.ts b/pages/api/index.ts index 370cdf4..9a6958e 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -12,8 +12,14 @@ 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)) + let encodedPath = pathPosix.join(basePath, path) if (encodedPath === '/' || encodedPath === '') { return '' } @@ -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() @@ -98,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() @@ -111,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 } @@ -150,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 diff --git a/pages/api/item.ts b/pages/api/item.ts new file mode 100644 index 0000000..8385c47 --- /dev/null +++ b/pages/api/item.ts @@ -0,0 +1,32 @@ +import axios from 'axios' +import type { NextApiRequest, NextApiResponse } from 'next' + +import { 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) diff --git a/types/index.d.ts b/types/index.d.ts index 140e4df..619f118 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -50,3 +50,17 @@ export declare type OdSearchResult = Array<{ path: string parentReference: { id: string; name: string; 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 bc4ada2..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 useFileContent(odRawUrl: string): { content: string; 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(odRawUrl) - .then(res => setContent(res.data)) + .get(fetchUrl) + .then(res => setResponse(res.data)) .catch(e => setError(e.message)) .finally(() => { setValidating(false) }) - }, [odRawUrl]) - return { content, error, validating } + }, [fetchUrl]) + return { response, error, validating } }