From 93376e071ecc55d71ed6d3f127b8cd3efee99f8b Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Fri, 17 Dec 2021 21:21:25 +0800 Subject: [PATCH] refactor new file download functions to new file --- components/Auth.tsx | 2 +- components/DownloadBtn.tsx | 2 +- components/FileListing.tsx | 14 +- components/previews/CodePreview.tsx | 4 +- components/previews/MarkdownPreview.tsx | 4 +- components/previews/TextPreview.tsx | 4 +- components/previews/VideoPreview.tsx | 2 +- pages/api/index.ts | 11 +- utils/{tools.ts => downloadMultipleFiles.ts} | 288 ++++++------------- utils/fetchWithSWR.ts | 65 +++++ utils/getBaseUrl.ts | 10 + utils/getFileIcon.ts | 6 +- utils/protectedRouteHandler.ts | 58 ++++ 13 files changed, 247 insertions(+), 223 deletions(-) rename utils/{tools.ts => downloadMultipleFiles.ts} (50%) create mode 100644 utils/fetchWithSWR.ts create mode 100644 utils/getBaseUrl.ts create mode 100644 utils/protectedRouteHandler.ts diff --git a/components/Auth.tsx b/components/Auth.tsx index fc27997..fcc4d53 100644 --- a/components/Auth.tsx +++ b/components/Auth.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { useRouter } from 'next/router' import { FunctionComponent } from 'react' -import { matchProtectedRoute } from '../utils/tools' +import { matchProtectedRoute } from '../utils/protectedRouteHandler' import useLocalStorage from '../utils/useLocalStorage' const Auth: FunctionComponent<{ redirect: string }> = ({ redirect }) => { diff --git a/components/DownloadBtn.tsx b/components/DownloadBtn.tsx index 13b6641..f74cb2c 100644 --- a/components/DownloadBtn.tsx +++ b/components/DownloadBtn.tsx @@ -4,7 +4,7 @@ import toast, { Toaster } from 'react-hot-toast' import { useRouter } from 'next/router' import { useClipboard } from 'use-clipboard-copy' -import { getBaseUrl } from '../utils/tools' +import { getBaseUrl } from "../utils/getBaseUrl" const DownloadBtn: FunctionComponent<{ downloadUrl: string }> = ({ downloadUrl }) => { const { asPath } = useRouter() diff --git a/components/FileListing.tsx b/components/FileListing.tsx index 45804a0..b424788 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -12,13 +12,9 @@ import dynamic from 'next/dynamic' import { getExtension, getFileIcon, hasKey } from '../utils/getFileIcon' import { extensions, preview } from '../utils/getPreviewType' -import { - getBaseUrl, - traverseFolder, - downloadMultipleFiles, - useProtectedSWRInfinite, - downloadTreelikeMultipleFiles, -} from '../utils/tools' +import { useProtectedSWRInfinite } from '../utils/fetchWithSWR' +import { getBaseUrl } from '../utils/getBaseUrl' +import { downloadMultipleFiles, downloadTreelikeMultipleFiles, traverseFolder } from '../utils/downloadMultipleFiles' import { VideoPreview } from './previews/VideoPreview' import { AudioPreview } from './previews/AudioPreview' @@ -290,7 +286,7 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) = } else if (files.length > 1) { setTotalGenerating(true) const toastId = toast.loading('Downloading selected files. Refresh to cancel, this may take some time...') - downloadMultipleFiles(toastId, files, folder) + downloadMultipleFiles({ toastId, files, folder }) .then(() => { setTotalGenerating(false) toast.dismiss(toastId) @@ -320,7 +316,7 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) = setFolderGenerating({ ...folderGenerating, [id]: true }) const toastId = toast.loading('Downloading folder. Refresh to cancel, this may take some time...') - downloadTreelikeMultipleFiles(toastId, files, path, name) + downloadTreelikeMultipleFiles({ toastId, files, basePath: path, folder: name }) .then(() => { setFolderGenerating({ ...folderGenerating, [id]: false }) toast.dismiss(toastId) diff --git a/components/previews/CodePreview.tsx b/components/previews/CodePreview.tsx index 710a332..1f1b873 100644 --- a/components/previews/CodePreview.tsx +++ b/components/previews/CodePreview.tsx @@ -2,13 +2,13 @@ import { useEffect, FunctionComponent } from 'react' import Prism from 'prismjs' import { getExtension } from '../../utils/getFileIcon' -import { useStaleSWR } from '../../utils/tools' +import { useStaleSWR } from '../../utils/fetchWithSWR' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadBtn from '../DownloadBtn' const CodePreview: FunctionComponent<{ file: any }> = ({ file }) => { - const { data, error } = useStaleSWR(file['@microsoft.graph.downloadUrl']) + const { data, error } = useStaleSWR({ url: file['@microsoft.graph.downloadUrl'] }) useEffect(() => { if (typeof window !== 'undefined') { diff --git a/components/previews/MarkdownPreview.tsx b/components/previews/MarkdownPreview.tsx index 987d977..0c80e5b 100644 --- a/components/previews/MarkdownPreview.tsx +++ b/components/previews/MarkdownPreview.tsx @@ -11,14 +11,14 @@ import 'katex/dist/katex.min.css' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadBtn from '../DownloadBtn' -import { useStaleSWR } from '../../utils/tools' +import { useStaleSWR } from '../../utils/fetchWithSWR' const MarkdownPreview: FunctionComponent<{ file: any; path: string; standalone?: boolean }> = ({ file, path, standalone = true, }) => { - const { data, error } = useStaleSWR(file['@microsoft.graph.downloadUrl']) + const { data, error } = useStaleSWR({ url: 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 d66e8b2..06817e6 100644 --- a/components/previews/TextPreview.tsx +++ b/components/previews/TextPreview.tsx @@ -3,10 +3,10 @@ import { FunctionComponent } from 'react' import FourOhFour from '../FourOhFour' import Loading from '../Loading' import DownloadBtn from '../DownloadBtn' -import { useStaleSWR } from '../../utils/tools' +import { useStaleSWR } from '../../utils/fetchWithSWR' const TextPreview: FunctionComponent<{ file: any }> = ({ file }) => { - const { data, error } = useStaleSWR(file['@microsoft.graph.downloadUrl']) + const { data, error } = useStaleSWR({ url: file['@microsoft.graph.downloadUrl'] }) if (error) { return (
diff --git a/components/previews/VideoPreview.tsx b/components/previews/VideoPreview.tsx index ccaa249..8cc0045 100644 --- a/components/previews/VideoPreview.tsx +++ b/components/previews/VideoPreview.tsx @@ -6,7 +6,7 @@ import { useClipboard } from 'use-clipboard-copy' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import toast, { Toaster } from 'react-hot-toast' -import { getBaseUrl } from '../../utils/tools' +import { getBaseUrl } from "../../utils/getBaseUrl" export const VideoPreview: FunctionComponent<{ file: any }> = ({ file }) => { const { asPath } = useRouter() diff --git a/pages/api/index.ts b/pages/api/index.ts index 5c87dac..64ad414 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -4,7 +4,7 @@ import { posix as pathPosix } from 'path' import apiConfig from '../../config/api.json' import siteConfig from '../../config/site.json' -import { compareHashedToken } from '../../utils/tools' +import { compareHashedToken } from '../../utils/protectedRouteHandler' const basePath = pathPosix.resolve('/', apiConfig.base) const encodePath = (path: string) => { @@ -77,7 +77,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const odProtectedToken = await axios.get(token.data['@microsoft.graph.downloadUrl']) // console.log(req.headers['od-protected-token'], odProtectedToken.data.trim()) - if (!compareHashedToken(req.headers['od-protected-token'] as string, odProtectedToken.data)) { + if ( + !compareHashedToken({ + odTokenHeader: req.headers['od-protected-token'] as string, + dotPassword: odProtectedToken.data, + }) + ) { res.status(401).json({ error: 'Password required for this folder.' }) return } @@ -127,7 +132,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }) if ('folder' in identityData) { - const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '': ':'}/children`, { + const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/children`, { headers: { Authorization: `Bearer ${accessToken}` }, params: next ? { diff --git a/utils/tools.ts b/utils/downloadMultipleFiles.ts similarity index 50% rename from utils/tools.ts rename to utils/downloadMultipleFiles.ts index 4603494..4113e14 100644 --- a/utils/tools.ts +++ b/utils/downloadMultipleFiles.ts @@ -1,141 +1,40 @@ -import axios from 'axios' -import sha256 from 'crypto-js/sha256' -import useSWR, { cache, Key, useSWRInfinite } from 'swr' import toast from 'react-hot-toast' import JSZip from 'jszip' -import siteConfig from '../config/site.json' +import { fetcher } from './fetchWithSWR' +import { getStoredToken } from './protectedRouteHandler' -/** - * Extract the current web page's base url - * @returns base url of the page - */ -export const getBaseUrl = () => { - if (typeof window !== 'undefined') { - return window.location.origin - } - return '' -} +// Blob download helper +export function downloadBlob({ blob, name }: { blob: Blob; name: string }) { + // Prepare for download + const el = document.createElement('a') + el.style.display = 'none' + document.body.appendChild(el) -// Common axios fetch function for use with useSWR -const fetcher = (url: string, token?: string) => { - return token - ? axios - .get(url, { - headers: { 'od-protected-token': token }, - }) - .then(res => res.data) - : axios.get(url).then(res => res.data) -} -/** - * Use stale SWR instead of revalidating on each request. Not ideal for this scenario but have to do - * if fetching serverside props from component instead of pages. - * @param url request url - * @returns useSWR instance - */ -export const useStaleSWR = (url: Key, path: string = '') => { - const revalidationOptions = { - revalidateOnMount: !(cache.has(`arg@"${url}"@null`) || cache.has(url)), - revalidateOnFocus: false, - revalidateOnReconnect: true, - } - - const hashedToken = getStoredToken(path) - return useSWR([url, hashedToken], fetcher, revalidationOptions) -} - -/** - * Paging with useSWRInfinite + protected token support - * @param path Current query directory path - * @returns useSWRInfinite API - */ -export const useProtectedSWRInfinite = (path: string = '') => { - const hashedToken = getStoredToken(path) - - /** - * Next page infinite loading for useSWR - * @param pageIdx The index of this paging collection - * @param prevPageData Previous page information - * @param path Directory path - * @returns API to the next page - */ - const getNextKey = (pageIndex, previousPageData) => { - // Reached the end of the collection - if (previousPageData && !previousPageData.folder) return null - - // First page with no prevPageData - if (pageIndex === 0) return [`/api?path=${path}`, hashedToken] - - // Add nextPage token to API endpoint - return [`/api?path=${path}&next=${previousPageData.next}`, hashedToken] - } - - const revalidationOptions = { - revalidateOnMount: !(cache.has(`arg@"/api?path=${path}"@null`) || cache.has(`/api?path=${path}`)), - revalidateOnFocus: false, - revalidateOnReconnect: true, - } - return useSWRInfinite(getNextKey, fetcher, revalidationOptions) -} - -// Hash password token with SHA256 -const encryptToken = (token: string) => { - return sha256(token).toString() -} -// Fetch stored token from localStorage and encrypt with SHA256 -const getStoredToken = (path: string) => { - const storedToken = - typeof window !== 'undefined' ? JSON.parse(localStorage.getItem(matchProtectedRoute(path)) as string) : '' - return storedToken ? encryptToken(storedToken) : null -} -/** - * Compares the hash of .password and od-protected-token header - * @param odTokenHeader od-protected-token header (sha256 hashed token) - * @param dotPassword non-hashed .password file - * @returns whether the two hashes are the same - */ -export const compareHashedToken = (odTokenHeader: string, dotPassword: string) => { - return encryptToken(dotPassword.trim()) === odTokenHeader -} - -/** - * Match the specified route against a list of predefined routes - * @param route directory path - * @returns whether the directory is protected - */ -export const matchProtectedRoute = (route: string) => { - const protectedRoutes: string[] = siteConfig.protectedRoutes - let authTokenPath = '' - - for (const r of protectedRoutes) { - // protected route array could be empty - if (r) { - if ( - route.startsWith( - r - .split('/') - .map(p => encodeURIComponent(p)) - .join('/') - ) - ) { - authTokenPath = r - break - } - } - } - return authTokenPath + // Download zip file + const bUrl = window.URL.createObjectURL(blob) + el.href = bUrl + el.download = name + el.click() + window.URL.revokeObjectURL(bUrl) + el.remove() } /** * Download multiple files after compressing them into a zip + * @param toastId Toast ID to be used for toast notification * @param files Files to be downloaded * @param folder Optional folder name to hold files, otherwise flatten files in the zip */ -export const downloadMultipleFiles = async ( - toastId: string, - files: { name: string; url: string }[], +export async function downloadMultipleFiles({ + toastId, + files, + folder, +}: { + toastId: string + files: { name: string; url: string }[] folder?: string -) => { +}): Promise { const zip = new JSZip() const dir = folder ? zip.folder(folder)! : zip @@ -155,23 +54,74 @@ export const downloadMultipleFiles = async ( id: toastId, }) }) - downloadBlob(b, folder ? folder + '.zip' : 'download.zip') + downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' }) } -// Blob download helper -const downloadBlob = (b: Blob, name: string) => { - // Prepare for download - const el = document.createElement('a') - el.style.display = 'none' - document.body.appendChild(el) +/** + * Download hierarchical tree-like files after compressing them into a zip + * @param toastId Toast ID to be used for toast notification + * @param files Files to be downloaded. Array of file and folder items excluding root folder. + * Folder items MUST be in front of its children items in the array. + * Use async generator because generation of the array may be slow. + * When waiting for its generation, we can meanwhile download bodies of already got items. + * Only folder items can have url undefined. + * @param basePath Root dir path of files to be downloaded + * @param folder Optional folder name to hold files, otherwise flatten files in the zip + */ - // Download zip file - const bUrl = window.URL.createObjectURL(b) - el.href = bUrl - el.download = name - el.click() - window.URL.revokeObjectURL(bUrl) - el.remove() +export async function downloadTreelikeMultipleFiles({ + toastId, + files, + basePath, + folder, +}: { + toastId: string + files: AsyncGenerator<{ + name: string + url?: string + path: string + isFolder: boolean + }> + basePath: string + folder?: string +}): Promise { + const zip = new JSZip() + const root = folder ? zip.folder(folder)! : zip + const map = [{ path: basePath, dir: root }] + + // Add selected file blobs to zip according to its path + for await (const { name, url, path, isFolder } of files) { + // Search parent dir in map + const i = map + .slice() + .reverse() + .findIndex( + ({ path: parent }) => + path.substring(0, parent.length) === parent && path.substring(parent.length + 1).indexOf('/') === -1 + ) + if (i === -1) { + throw new Error('File array does not satisfy requirement') + } + + // Add file or folder to zip + const dir = map[map.length - 1 - i].dir + if (isFolder) { + map.push({ path, dir: dir.folder(name)! }) + } else { + dir.file( + name, + fetch(url!).then(r => r.blob()) + ) + } + } + + // Create zip file and download it + const b = await zip.generateAsync({ type: 'blob' }, metadata => { + toast.loading(`Downloading ${metadata.percent.toFixed(0)}%. Refresh to cancel...`, { + id: toastId, + }) + }) + downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' }) } /** @@ -218,63 +168,3 @@ export async function* traverseFolder(path: string): AsyncGenerator< folderPaths = items.filter(i => i.isFolder).map(i => i.path) } } - -/** - * Download hierarchical tree-like files after compressing them into a zip - * @param files Files to be downloaded. Array of file and folder items excluding root folder. - * Folder items MUST be in front of its children items in the array. - * Use async generator because generation of the array may be slow. - * When waiting for its generation, we can meanwhile download bodies of already got items. - * Only folder items can have url undefined. - * @param basePath Root dir path of files to be downloaded - * @param folder Optional folder name to hold files, otherwise flatten files in the zip - */ -export const downloadTreelikeMultipleFiles = async ( - toastId: string, - files: AsyncGenerator<{ - name: string - url?: string - path: string - isFolder: boolean - }>, - basePath: string, - folder?: string -) => { - const zip = new JSZip() - const root = folder ? zip.folder(folder)! : zip - const map = [{ path: basePath, dir: root }] - - // Add selected file blobs to zip according to its path - for await (const { name, url, path, isFolder } of files) { - // Search parent dir in map - const i = map - .slice() - .reverse() - .findIndex( - ({ path: parent }) => - path.substring(0, parent.length) === parent && path.substring(parent.length + 1).indexOf('/') === -1 - ) - if (i === -1) { - throw new Error('File array does not satisfy requirement') - } - - // Add file or folder to zip - const dir = map[map.length - 1 - i].dir - if (isFolder) { - map.push({ path, dir: dir.folder(name)! }) - } else { - dir.file( - name, - fetch(url!).then(r => r.blob()) - ) - } - } - - // Create zip file and download it - const b = await zip.generateAsync({ type: 'blob' }, metadata => { - toast.loading(`Downloading ${metadata.percent.toFixed(0)}%. Refresh to cancel...`, { - id: toastId, - }) - }) - downloadBlob(b, folder ? folder + '.zip' : 'download.zip') -} diff --git a/utils/fetchWithSWR.ts b/utils/fetchWithSWR.ts new file mode 100644 index 0000000..b9a1b85 --- /dev/null +++ b/utils/fetchWithSWR.ts @@ -0,0 +1,65 @@ +import axios from 'axios' +import useSWR, { cache, Key, useSWRInfinite } from 'swr' + +import { getStoredToken } from './protectedRouteHandler' + +// Common axios fetch function for use with useSWR +export function fetcher(url: string, token?: string): Promise { + return token + ? axios + .get(url, { + headers: { 'od-protected-token': token }, + }) + .then(res => res.data) + : axios.get(url).then(res => res.data) +} +/** + * Use stale SWR instead of revalidating on each request. Not ideal for this scenario but have to do + * if fetching serverside props from component instead of pages. + * @param url request url + * @returns useSWR instance + */ +export function useStaleSWR({ url, path = '' }: { url: Key; path?: string }) { + const revalidationOptions = { + revalidateOnMount: !(cache.has(`arg@"${url}"@null`) || cache.has(url)), + revalidateOnFocus: false, + revalidateOnReconnect: true, + } + + const hashedToken = getStoredToken(path) + return useSWR([url, hashedToken], fetcher, revalidationOptions) +} + +/** + * Paging with useSWRInfinite + protected token support + * @param path Current query directory path + * @returns useSWRInfinite API + */ +export function useProtectedSWRInfinite(path: string = '') { + const hashedToken = getStoredToken(path) + + /** + * Next page infinite loading for useSWR + * @param pageIdx The index of this paging collection + * @param prevPageData Previous page information + * @param path Directory path + * @returns API to the next page + */ + function getNextKey(pageIndex, previousPageData): (string | null)[] | null { + // Reached the end of the collection + if (previousPageData && !previousPageData.folder) return null + + // First page with no prevPageData + if (pageIndex === 0) return [`/api?path=${path}`, hashedToken] + + // Add nextPage token to API endpoint + return [`/api?path=${path}&next=${previousPageData.next}`, hashedToken] + } + + const revalidationOptions = { + revalidateOnMount: !(cache.has(`arg@"/api?path=${path}"@null`) || cache.has(`/api?path=${path}`)), + revalidateOnFocus: false, + revalidateOnReconnect: true, + } + return useSWRInfinite(getNextKey, fetcher, revalidationOptions) +} diff --git a/utils/getBaseUrl.ts b/utils/getBaseUrl.ts new file mode 100644 index 0000000..a0de661 --- /dev/null +++ b/utils/getBaseUrl.ts @@ -0,0 +1,10 @@ +/** + * Extract the current web page's base url + * @returns base url of the page + */ +export function getBaseUrl(): string { + if (typeof window !== 'undefined') { + return window.location.origin + } + return '' +} diff --git a/utils/getFileIcon.ts b/utils/getFileIcon.ts index 0a851c6..0a1c711 100644 --- a/utils/getFileIcon.ts +++ b/utils/getFileIcon.ts @@ -83,15 +83,15 @@ const extensions = { * @param key The index key * @returns Whether or not the key exists inside the object */ -export const hasKey = (obj: O, key: PropertyKey): key is keyof O => { +export function hasKey(obj: O, key: PropertyKey): key is keyof O { return key in obj } -export const getExtension = (fileName: string) => { +export function getExtension(fileName: string): string { return fileName.slice(((fileName.lastIndexOf('.') - 1) >>> 0) + 2).toLowerCase() } -export const getFileIcon = (fileName: string) => { +export function getFileIcon(fileName: string): [IconPrefix, IconName] { const extension = getExtension(fileName) return hasKey(extensions, extension) ? extensions[extension] : icons.file } diff --git a/utils/protectedRouteHandler.ts b/utils/protectedRouteHandler.ts new file mode 100644 index 0000000..88df9a1 --- /dev/null +++ b/utils/protectedRouteHandler.ts @@ -0,0 +1,58 @@ +import sha256 from 'crypto-js/sha256' +import siteConfig from '../config/site.json' + +// Hash password token with SHA256 +function encryptToken(token: string): string { + return sha256(token).toString() +} + +// Fetch stored token from localStorage and encrypt with SHA256 +export function getStoredToken(path: string): string | null { + const storedToken = + typeof window !== 'undefined' ? JSON.parse(localStorage.getItem(matchProtectedRoute(path)) as string) : '' + return storedToken ? encryptToken(storedToken) : null +} + +/** + * Compares the hash of .password and od-protected-token header + * @param odTokenHeader od-protected-token header (sha256 hashed token) + * @param dotPassword non-hashed .password file + * @returns whether the two hashes are the same + */ +export function compareHashedToken({ + odTokenHeader, + dotPassword, +}: { + odTokenHeader: string + dotPassword: string +}): boolean { + return encryptToken(dotPassword.trim()) === odTokenHeader +} +/** + * Match the specified route against a list of predefined routes + * @param route directory path + * @returns whether the directory is protected + */ + +export function matchProtectedRoute(route: string): string { + const protectedRoutes: string[] = siteConfig.protectedRoutes + let authTokenPath = '' + + for (const r of protectedRoutes) { + // protected route array could be empty + if (r) { + if ( + route.startsWith( + r + .split('/') + .map(p => encodeURIComponent(p)) + .join('/') + ) + ) { + authTokenPath = r + break + } + } + } + return authTokenPath +}