8dcb881917
change DFS to BFS with same level folders fetched concurrently. merge helper class into traversing func.
240 lines
7.9 KiB
TypeScript
240 lines
7.9 KiB
TypeScript
import axios from 'axios'
|
|
import sha256 from 'crypto-js/sha256'
|
|
import useSWR, { cache, Key, useSWRInfinite } from 'swr'
|
|
import JSZip from 'jszip'
|
|
|
|
import siteConfig from '../config/site.json'
|
|
|
|
/**
|
|
* 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 ''
|
|
}
|
|
|
|
// 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 multiple files after compressing them into a zip
|
|
* @param files Files to be downloaded
|
|
* @param folder Optional folder name to hold files, otherwise flatten files in the zip
|
|
*/
|
|
export const downloadMultipleFiles = async (files: { name: string; url: string }[], folder?: string) => {
|
|
const zip = new JSZip()
|
|
const dir = folder ? zip.folder(folder)! : zip
|
|
|
|
// Add selected file blobs to zip
|
|
files.forEach(({ name, url }) => {
|
|
dir.file(
|
|
name,
|
|
fetch(url).then(r => r.blob())
|
|
)
|
|
})
|
|
|
|
// Create zip file and download it
|
|
const b = await zip.generateAsync({ type: 'blob' })
|
|
downloadBlob(b, 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 zip file
|
|
const bUrl = window.URL.createObjectURL(b)
|
|
el.href = bUrl
|
|
el.download = name
|
|
el.click()
|
|
window.URL.revokeObjectURL(bUrl)
|
|
el.remove()
|
|
}
|
|
|
|
/**
|
|
* One-shot concurrent BFS file traversing for the folder.
|
|
* Due to react hook limit, we cannot reuse SWR utils for recursive actions.
|
|
* We will directly fetch API and arrange responses instead.
|
|
* In folder tree, we visit folders with same level concurrently.
|
|
* Every time we visit a folder, we fetch and return meta of all its children.
|
|
* @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.
|
|
*/
|
|
export async function* traverseFolder(path: string): AsyncGenerator<{
|
|
path: string, meta: any, isFolder: boolean
|
|
}, void, undefined> {
|
|
const hashedToken = getStoredToken(path)
|
|
let folderPaths = [path]
|
|
while (folderPaths.length > 0) {
|
|
const itemLists = await Promise.all(folderPaths.map(fp => (async (fp) => {
|
|
const data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined)
|
|
if (data && data.folder) {
|
|
return data.folder.value.map((c: any) => {
|
|
const p = `${fp === '/' ? '' : fp}/${encodeURIComponent(c.name)}`
|
|
return { path: p, meta: c, isFolder: Boolean(c.folder) }
|
|
})
|
|
} else {
|
|
throw new Error('Path is not folder')
|
|
}
|
|
})(fp)))
|
|
const items = itemLists.flat() as { path: string, meta: any, isFolder: boolean }[]
|
|
yield* items
|
|
folderPaths = items.filter(i => i.isFolder).map(i => i.path)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download hieratical 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 (
|
|
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' })
|
|
downloadBlob(b, folder ? folder + '.zip' : 'download.zip')
|
|
}
|