feat: use src/ dir for holding files

This commit is contained in:
spencerwooo
2023-01-26 15:31:07 +08:00
parent d00a316bda
commit 799a423dbd
56 changed files with 26 additions and 26 deletions
+63
View File
@@ -0,0 +1,63 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { FC, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { matchProtectedRoute } from '../utils/protectedRouteHandler'
import useLocalStorage from '../utils/useLocalStorage'
const Auth: FC<{ redirect: string }> = ({ redirect }) => {
const authTokenPath = matchProtectedRoute(redirect)
const router = useRouter()
const [token, setToken] = useState('')
const [_, setPersistedToken] = useLocalStorage(authTokenPath, '')
const { t } = useTranslation()
return (
<div className="mx-auto flex max-w-sm flex-col space-y-4 md:my-10">
<div className="mx-auto w-3/4 md:w-5/6">
<Image src={'/images/fabulous-wapmire-weekdays.png'} alt="authenticate" width={912} height={912} priority />
</div>
<div className="text-lg font-bold text-gray-900 dark:text-gray-100">{t('Enter Password')}</div>
<p className="text-sm font-medium text-gray-500">
{t('This route (the folder itself and the files inside) is password protected. ') +
t('If you know the password, please enter it below.')}
</p>
<div className="flex items-center space-x-2">
<input
className="flex-1 rounded border border-gray-600/10 p-2 font-mono focus:outline-none focus:ring focus:ring-blue-300 dark:bg-gray-600 dark:text-white dark:focus:ring-blue-700"
autoFocus
type="password"
placeholder="************"
value={token}
onChange={e => {
setToken(e.target.value)
}}
onKeyPress={e => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
setPersistedToken(token)
router.reload()
}
}}
/>
<button
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500 focus:outline-none focus:ring focus:ring-blue-400"
onClick={() => {
setPersistedToken(token)
router.reload()
}}
>
<FontAwesomeIcon icon="arrow-right" />
</button>
</div>
</div>
)
}
export default Auth
+61
View File
@@ -0,0 +1,61 @@
import type { ParsedUrlQuery } from 'querystring'
import Link from 'next/link'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation } from 'next-i18next'
const HomeCrumb = () => {
const { t } = useTranslation()
return (
<Link href="/" className="flex items-center">
<FontAwesomeIcon className="h-3 w-3" icon={['far', 'flag']} />
<span className="ml-2 font-medium">{t('Home')}</span>
</Link>
)
}
const Breadcrumb: React.FC<{ query?: ParsedUrlQuery }> = ({ query }) => {
if (query) {
const { path } = query
if (Array.isArray(path)) {
// We are rendering the path in reverse, so that the browser automatically scrolls to the end of the breadcrumb
// https://stackoverflow.com/questions/18614301/keep-overflow-div-scrolled-to-bottom-unless-user-scrolls-up/18614561
return (
<ol className="no-scrollbar inline-flex flex-row-reverse items-center gap-1 overflow-x-scroll text-sm text-gray-600 dark:text-gray-300 md:gap-3">
{path
.slice(0)
.reverse()
.map((p: string, i: number) => (
<li key={i} className="flex flex-shrink-0 items-center">
<FontAwesomeIcon className="h-3 w-3" icon="angle-right" />
<Link
href={`/${path
.slice(0, path.length - i)
.map(p => encodeURIComponent(p))
.join('/')}`}
passHref
className={`ml-1 transition-all duration-75 hover:opacity-70 md:ml-3 ${
i == 0 && 'pointer-events-none opacity-80'
}`}
>
{p}
</Link>
</li>
))}
<li className="flex-shrink-0 transition-all duration-75 hover:opacity-80">
<HomeCrumb />
</li>
</ol>
)
}
}
return (
<div className="text-sm text-gray-600 transition-all duration-75 hover:opacity-80 dark:text-gray-300">
<HomeCrumb />
</div>
)
}
export default Breadcrumb
+131
View File
@@ -0,0 +1,131 @@
import { Dispatch, Fragment, SetStateAction, useRef, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { Dialog, Transition } from '@headlessui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useClipboard } from 'use-clipboard-copy'
import { getBaseUrl } from '../utils/getBaseUrl'
import { getStoredToken } from '../utils/protectedRouteHandler'
import { getReadablePath } from '../utils/getReadablePath'
function LinkContainer({ title, value }: { title: string; value: string }) {
const clipboard = useClipboard({ copiedTimeout: 1000 })
return (
<>
<h4 className="py-2 text-xs font-medium uppercase tracking-wider">{title}</h4>
<div className="group relative mb-2 max-h-24 overflow-y-scroll break-all rounded border border-gray-400/20 bg-gray-50 p-2.5 font-mono dark:bg-gray-800">
<div className="opacity-80">{value}</div>
<button
onClick={() => clipboard.copy(value)}
className="absolute top-[0.2rem] right-[0.2rem] w-8 rounded border border-gray-400/40 bg-gray-100 py-1.5 opacity-0 transition-all duration-100 hover:bg-gray-200 group-hover:opacity-100 dark:bg-gray-850 dark:hover:bg-gray-700"
>
{clipboard.copied ? <FontAwesomeIcon icon="check" /> : <FontAwesomeIcon icon="copy" />}
</button>
</div>
</>
)
}
export default function CustomEmbedLinkMenu({
path,
menuOpen,
setMenuOpen,
}: {
path: string
menuOpen: boolean
setMenuOpen: Dispatch<SetStateAction<boolean>>
}) {
const { t } = useTranslation()
const hashedToken = getStoredToken(path)
// Focus on input automatically when menu modal opens
const focusInputRef = useRef<HTMLInputElement>(null)
const closeMenu = () => setMenuOpen(false)
const readablePath = getReadablePath(path)
const filename = readablePath.substring(readablePath.lastIndexOf('/') + 1)
const [name, setName] = useState(filename)
return (
<Transition appear show={menuOpen} as={Fragment}>
<Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" onClose={closeMenu} initialFocus={focusInputRef}>
<div className="min-h-screen px-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-white/60 dark:bg-gray-800/60" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block h-screen align-middle" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="inline-block max-h-[80vh] w-full max-w-3xl transform overflow-hidden overflow-y-scroll rounded border border-gray-400/30 bg-white p-4 text-left align-middle text-sm shadow-xl transition-all dark:bg-gray-900 dark:text-white">
<Dialog.Title as="h3" className="py-2 text-xl font-bold">
{t('Customise direct link')}
</Dialog.Title>
<Dialog.Description as="p" className="py-2 opacity-80">
<>
{t('Change the raw file direct link to a URL ending with the extension of the file.')}{' '}
<a
href="https://ovi.swo.moe/docs/features/customise-direct-link"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline"
>
{t('What is this?')}
</a>
</>
</Dialog.Description>
<div className="mt-4">
<h4 className="py-2 text-xs font-medium uppercase tracking-wider">{t('Filename')}</h4>
<input
className="mb-2 w-full rounded border border-gray-600/10 p-2.5 font-mono focus:outline-none focus:ring focus:ring-blue-300 dark:bg-gray-600 dark:text-white dark:focus:ring-blue-700"
ref={focusInputRef}
value={name}
onChange={e => setName(e.target.value)}
/>
<LinkContainer
title={t('Default')}
value={`${getBaseUrl()}/api/raw/?path=${readablePath}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
/>
<LinkContainer
title={t('URL encoded')}
value={`${getBaseUrl()}/api/raw/?path=${path}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
/>
<LinkContainer
title={t('Customised')}
value={`${getBaseUrl()}/api/name/${name}?path=${readablePath}${
hashedToken ? `&odpt=${hashedToken}` : ''
}`}
/>
<LinkContainer
title={t('Customised and encoded')}
value={`${getBaseUrl()}/api/name/${name}?path=${path}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
/>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { MouseEventHandler, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconProp } from '@fortawesome/fontawesome-svg-core'
import toast from 'react-hot-toast'
import { useClipboard } from 'use-clipboard-copy'
import { useTranslation } from 'next-i18next'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { getBaseUrl } from '../utils/getBaseUrl'
import { getStoredToken } from '../utils/protectedRouteHandler'
import CustomEmbedLinkMenu from './CustomEmbedLinkMenu'
const btnStyleMap = (btnColor?: string) => {
const colorMap = {
gray: 'hover:text-gray-600 dark:hover:text-white focus:ring-gray-200 focus:text-gray-600 dark:focus:text-white border-gray-300 dark:border-gray-500 dark:focus:ring-gray-500',
blue: 'hover:text-blue-600 focus:ring-blue-200 focus:text-blue-600 border-blue-300 dark:border-blue-700 dark:focus:ring-blue-500',
teal: 'hover:text-teal-600 focus:ring-teal-200 focus:text-teal-600 border-teal-300 dark:border-teal-700 dark:focus:ring-teal-500',
red: 'hover:text-red-600 focus:ring-red-200 focus:text-red-600 border-red-300 dark:border-red-700 dark:focus:ring-red-500',
green:
'hover:text-green-600 focus:ring-green-200 focus:text-green-600 border-green-300 dark:border-green-700 dark:focus:ring-green-500',
pink: 'hover:text-pink-600 focus:ring-pink-200 focus:text-pink-600 border-pink-300 dark:border-pink-700 dark:focus:ring-pink-500',
yellow:
'hover:text-yellow-400 focus:ring-yellow-100 focus:text-yellow-400 border-yellow-300 dark:border-yellow-400 dark:focus:ring-yellow-300',
}
if (btnColor) {
return colorMap[btnColor]
}
return colorMap.gray
}
export const DownloadButton = ({
onClickCallback,
btnColor,
btnText,
btnIcon,
btnImage,
btnTitle,
}: {
onClickCallback: MouseEventHandler<HTMLButtonElement>
btnColor?: string
btnText: string
btnIcon?: IconProp
btnImage?: string
btnTitle?: string
}) => {
return (
<button
className={`flex items-center space-x-2 rounded-lg border bg-white py-2 px-4 text-sm font-medium text-gray-900 hover:bg-gray-100/10 focus:z-10 focus:ring-2 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-900 ${btnStyleMap(
btnColor
)}`}
title={btnTitle}
onClick={onClickCallback}
>
{btnIcon && <FontAwesomeIcon icon={btnIcon} />}
{btnImage && <Image src={btnImage} alt={btnImage} width={20} height={20} priority />}
<span>{btnText}</span>
</button>
)
}
const DownloadButtonGroup = () => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const clipboard = useClipboard()
const [menuOpen, setMenuOpen] = useState(false)
const { t } = useTranslation()
return (
<>
<CustomEmbedLinkMenu menuOpen={menuOpen} setMenuOpen={setMenuOpen} path={asPath} />
<div className="flex flex-wrap justify-center gap-2">
<DownloadButton
onClickCallback={() => window.open(`/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`)}
btnColor="blue"
btnText={t('Download')}
btnIcon="file-download"
btnTitle={t('Download the file directly through OneDrive')}
/>
<DownloadButton
onClickCallback={() => {
clipboard.copy(`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`)
toast.success(t('Copied direct link to clipboard.'))
}}
btnColor="pink"
btnText={t('Copy direct link')}
btnIcon="copy"
btnTitle={t('Copy the permalink to the file to the clipboard')}
/>
<DownloadButton
onClickCallback={() => setMenuOpen(true)}
btnColor="teal"
btnText={t('Customise link')}
btnIcon="pen"
/>
</div>
</>
)
}
export default DownloadButtonGroup
+442
View File
@@ -0,0 +1,442 @@
import type { OdFileObject, OdFolderChildren, OdFolderObject } from '../types'
import { ParsedUrlQuery } from 'querystring'
import { FC, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import toast, { Toaster } from 'react-hot-toast'
import emojiRegex from 'emoji-regex'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import useLocalStorage from '../utils/useLocalStorage'
import { getPreviewType, preview } from '../utils/getPreviewType'
import { useProtectedSWRInfinite } from '../utils/fetchWithSWR'
import { getExtension, getRawExtension, getFileIcon } from '../utils/getFileIcon'
import { getStoredToken } from '../utils/protectedRouteHandler'
import {
DownloadingToast,
downloadMultipleFiles,
downloadTreelikeMultipleFiles,
traverseFolder,
} from './MultiFileDownloader'
import { layouts } from './SwitchLayout'
import Loading, { LoadingIcon } from './Loading'
import FourOhFour from './FourOhFour'
import Auth from './Auth'
import TextPreview from './previews/TextPreview'
import MarkdownPreview from './previews/MarkdownPreview'
import CodePreview from './previews/CodePreview'
import OfficePreview from './previews/OfficePreview'
import AudioPreview from './previews/AudioPreview'
import VideoPreview from './previews/VideoPreview'
import PDFPreview from './previews/PDFPreview'
import URLPreview from './previews/URLPreview'
import ImagePreview from './previews/ImagePreview'
import DefaultPreview from './previews/DefaultPreview'
import { PreviewContainer } from './previews/Containers'
import FolderListLayout from './FolderListLayout'
import FolderGridLayout from './FolderGridLayout'
// Disabling SSR for some previews
const EPUBPreview = dynamic(() => import('./previews/EPUBPreview'), {
ssr: false,
})
/**
* Convert url query into path string
*
* @param query Url query property
* @returns Path string
*/
const queryToPath = (query?: ParsedUrlQuery) => {
if (query) {
const { path } = query
if (!path) return '/'
if (typeof path === 'string') return `/${encodeURIComponent(path)}`
return `/${path.map(p => encodeURIComponent(p)).join('/')}`
}
return '/'
}
// Render the icon of a folder child (may be a file or a folder), use emoji if the name of the child contains emoji
const renderEmoji = (name: string) => {
const emoji = emojiRegex().exec(name)
return { render: emoji && !emoji.index, emoji }
}
const formatChildName = (name: string) => {
const { render, emoji } = renderEmoji(name)
return render ? name.replace(emoji ? emoji[0] : '', '').trim() : name
}
export const ChildName: FC<{ name: string; folder?: boolean }> = ({ name, folder }) => {
const original = formatChildName(name)
const extension = folder ? '' : getRawExtension(original)
const prename = folder ? original : original.substring(0, original.length - extension.length)
return (
<span className="truncate before:float-right before:content-[attr(data-tail)]" data-tail={extension}>
{prename}
</span>
)
}
export const ChildIcon: FC<{ child: OdFolderChildren }> = ({ child }) => {
const { render, emoji } = renderEmoji(child.name)
return render ? (
<span>{emoji ? emoji[0] : '📁'}</span>
) : (
<FontAwesomeIcon icon={child.file ? getFileIcon(child.name, { video: Boolean(child.video) }) : ['far', 'folder']} />
)
}
export const Checkbox: FC<{
checked: 0 | 1 | 2
onChange: () => void
title: string
indeterminate?: boolean
}> = ({ checked, onChange, title, indeterminate }) => {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) {
ref.current.checked = Boolean(checked)
if (indeterminate) {
ref.current.indeterminate = checked == 1
}
}
}, [ref, checked, indeterminate])
const handleClick: MouseEventHandler = e => {
if (ref.current) {
if (e.target === ref.current) {
e.stopPropagation()
} else {
ref.current.click()
}
}
}
return (
<span
title={title}
className="inline-flex cursor-pointer items-center rounded p-1.5 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={handleClick}
>
<input
className="form-check-input cursor-pointer"
type="checkbox"
value={checked ? '1' : ''}
ref={ref}
aria-label={title}
onChange={onChange}
/>
</span>
)
}
export const Downloading: FC<{ title: string; style: string }> = ({ title, style }) => {
return (
<span title={title} className={`${style} rounded`} role="status">
<LoadingIcon
// Use fontawesome far theme via class `svg-inline--fa` to get style `vertical-align` only
// for consistent icon alignment, as class `align-*` cannot satisfy it
className="svg-inline--fa inline-block h-4 w-4 animate-spin"
/>
</span>
)
}
const FileListing: FC<{ query?: ParsedUrlQuery }> = ({ query }) => {
const [selected, setSelected] = useState<{ [key: string]: boolean }>({})
const [totalSelected, setTotalSelected] = useState<0 | 1 | 2>(0)
const [totalGenerating, setTotalGenerating] = useState<boolean>(false)
const [folderGenerating, setFolderGenerating] = useState<{
[key: string]: boolean
}>({})
const router = useRouter()
const hashedToken = getStoredToken(router.asPath)
const [layout, _] = useLocalStorage('preferredLayout', layouts[0])
const { t } = useTranslation()
const path = queryToPath(query)
const { data, error, size, setSize } = useProtectedSWRInfinite(path)
if (error) {
// If error includes 403 which means the user has not completed initial setup, redirect to OAuth page
if (error.status === 403) {
router.push('/onedrive-vercel-index-oauth/step-1')
return <div />
}
return (
<PreviewContainer>
{error.status === 401 ? <Auth redirect={path} /> : <FourOhFour errorMsg={JSON.stringify(error.message)} />}
</PreviewContainer>
)
}
if (!data) {
return (
<PreviewContainer>
<Loading loadingText={t('Loading ...')} />
</PreviewContainer>
)
}
const responses: any[] = data ? [].concat(...data) : []
const isLoadingInitialData = !data && !error
const isLoadingMore = isLoadingInitialData || (size > 0 && data && typeof data[size - 1] === 'undefined')
const isEmpty = data?.[0]?.length === 0
const isReachingEnd = isEmpty || (data && typeof data[data.length - 1]?.next === 'undefined')
const onlyOnePage = data && typeof data[0].next === 'undefined'
if ('folder' in responses[0]) {
// Expand list of API returns into flattened file data
const folderChildren = [].concat(...responses.map(r => r.folder.value)) as OdFolderObject['value']
// Find README.md file to render
const readmeFile = folderChildren.find(c => c.name.toLowerCase() === 'readme.md')
// Filtered file list helper
const getFiles = () => folderChildren.filter(c => !c.folder && c.name !== '.password')
// File selection
const genTotalSelected = (selected: { [key: string]: boolean }) => {
const selectInfo = getFiles().map(c => Boolean(selected[c.id]))
const [hasT, hasF] = [selectInfo.some(i => i), selectInfo.some(i => !i)]
return hasT && hasF ? 1 : !hasF ? 2 : 0
}
const toggleItemSelected = (id: string) => {
let val: SetStateAction<{ [key: string]: boolean }>
if (selected[id]) {
val = { ...selected }
delete val[id]
} else {
val = { ...selected, [id]: true }
}
setSelected(val)
setTotalSelected(genTotalSelected(val))
}
const toggleTotalSelected = () => {
if (genTotalSelected(selected) == 2) {
setSelected({})
setTotalSelected(0)
} else {
setSelected(Object.fromEntries(getFiles().map(c => [c.id, true])))
setTotalSelected(2)
}
}
// Selected file download
const handleSelectedDownload = () => {
const folderName = path.substring(path.lastIndexOf('/') + 1)
const folder = folderName ? decodeURIComponent(folderName) : undefined
const files = getFiles()
.filter(c => selected[c.id])
.map(c => ({
name: c.name,
url: `/api/raw/?path=${path}/${encodeURIComponent(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`,
}))
if (files.length == 1) {
const el = document.createElement('a')
el.style.display = 'none'
document.body.appendChild(el)
el.href = files[0].url
el.click()
el.remove()
} else if (files.length > 1) {
setTotalGenerating(true)
const toastId = toast.loading(<DownloadingToast router={router} />)
downloadMultipleFiles({ toastId, router, files, folder })
.then(() => {
setTotalGenerating(false)
toast.success(t('Finished downloading selected files.'), {
id: toastId,
})
})
.catch(() => {
setTotalGenerating(false)
toast.error(t('Failed to download selected files.'), { id: toastId })
})
}
}
// Get selected file permalink
const handleSelectedPermalink = (baseUrl: string) => {
return getFiles()
.filter(c => selected[c.id])
.map(
c =>
`${baseUrl}/api/raw/?path=${path}/${encodeURIComponent(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`
)
.join('\n')
}
// Folder recursive download
const handleFolderDownload = (path: string, id: string, name?: string) => () => {
const files = (async function* () {
for await (const { meta: c, path: p, isFolder, error } of traverseFolder(path)) {
if (error) {
toast.error(
t('Failed to download folder {{path}}: {{status}} {{message}} Skipped it to continue.', {
path: p,
status: error.status,
message: error.message,
})
)
continue
}
const hashedTokenForPath = getStoredToken(p)
yield {
name: c?.name,
url: `/api/raw/?path=${p}${hashedTokenForPath ? `&odpt=${hashedTokenForPath}` : ''}`,
path: p,
isFolder,
}
}
})()
setFolderGenerating({ ...folderGenerating, [id]: true })
const toastId = toast.loading(<DownloadingToast router={router} />)
downloadTreelikeMultipleFiles({
toastId,
router,
files,
basePath: path,
folder: name,
})
.then(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
toast.success(t('Finished downloading folder.'), { id: toastId })
})
.catch(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
toast.error(t('Failed to download folder.'), { id: toastId })
})
}
// Folder layout component props
const folderProps = {
toast,
path,
folderChildren,
selected,
toggleItemSelected,
totalSelected,
toggleTotalSelected,
totalGenerating,
handleSelectedDownload,
folderGenerating,
handleSelectedPermalink,
handleFolderDownload,
}
return (
<>
<Toaster />
{layout.name === 'Grid' ? <FolderGridLayout {...folderProps} /> : <FolderListLayout {...folderProps} />}
{!onlyOnePage && (
<div className="rounded-b bg-white dark:bg-gray-900 dark:text-gray-100">
<div className="border-b border-gray-200 p-3 text-center font-mono text-sm text-gray-400 dark:border-gray-700">
{t('- showing {{count}} page(s) ', {
count: size,
totalFileNum: isLoadingMore ? '...' : folderChildren.length,
}) +
(isLoadingMore
? t('of {{count}} file(s) -', { count: folderChildren.length, context: 'loading' })
: t('of {{count}} file(s) -', { count: folderChildren.length, context: 'loaded' }))}
</div>
<button
className={`flex w-full items-center justify-center space-x-2 p-3 disabled:cursor-not-allowed ${
isLoadingMore || isReachingEnd ? 'opacity-60' : 'hover:bg-gray-100 dark:hover:bg-gray-850'
}`}
onClick={() => setSize(size + 1)}
disabled={isLoadingMore || isReachingEnd}
>
{isLoadingMore ? (
<>
<LoadingIcon className="inline-block h-4 w-4 animate-spin" />
<span>{t('Loading ...')}</span>{' '}
</>
) : isReachingEnd ? (
<span>{t('No more files')}</span>
) : (
<>
<span>{t('Load more')}</span>
<FontAwesomeIcon icon="chevron-circle-down" />
</>
)}
</button>
</div>
)}
{readmeFile && (
<div className="mt-4">
<MarkdownPreview file={readmeFile} path={path} standalone={false} />
</div>
)}
</>
)
}
if ('file' in responses[0] && responses.length === 1) {
const file = responses[0].file as OdFileObject
const previewType = getPreviewType(getExtension(file.name), { video: Boolean(file.video) })
if (previewType) {
switch (previewType) {
case preview.image:
return <ImagePreview file={file} />
case preview.text:
return <TextPreview file={file} />
case preview.code:
return <CodePreview file={file} />
case preview.markdown:
return <MarkdownPreview file={file} path={path} />
case preview.video:
return <VideoPreview file={file} />
case preview.audio:
return <AudioPreview file={file} />
case preview.pdf:
return <PDFPreview file={file} />
case preview.office:
return <OfficePreview file={file} />
case preview.epub:
return <EPUBPreview file={file} />
case preview.url:
return <URLPreview file={file} />
default:
return <DefaultPreview file={file} />
}
} else {
return <DefaultPreview file={file} />
}
}
return (
<PreviewContainer>
<FourOhFour errorMsg={t('Cannot preview {{path}}', { path })} />
</PreviewContainer>
)
}
export default FileListing
+200
View File
@@ -0,0 +1,200 @@
import type { OdFolderChildren } from '../types'
import Link from 'next/link'
import { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useClipboard } from 'use-clipboard-copy'
import { useTranslation } from 'next-i18next'
import { getBaseUrl } from '../utils/getBaseUrl'
import { formatModifiedDateTime } from '../utils/fileDetails'
import { Checkbox, ChildIcon, ChildName, Downloading } from './FileListing'
import { getStoredToken } from '../utils/protectedRouteHandler'
const GridItem = ({ c, path }: { c: OdFolderChildren; path: string }) => {
// We use the generated medium thumbnail for rendering preview images (excluding folders)
const hashedToken = getStoredToken(path)
const thumbnailUrl =
'folder' in c ? null : `/api/thumbnail/?path=${path}&size=medium${hashedToken ? `&odpt=${hashedToken}` : ''}`
// Some thumbnails are broken, so we check for onerror event in the image component
const [brokenThumbnail, setBrokenThumbnail] = useState(false)
return (
<div className="space-y-2">
<div className="h-32 overflow-hidden rounded border border-gray-900/10 dark:border-gray-500/30">
{thumbnailUrl && !brokenThumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="h-full w-full object-cover object-top"
src={thumbnailUrl}
alt={c.name}
onError={() => setBrokenThumbnail(true)}
/>
) : (
<div className="relative flex h-full w-full items-center justify-center rounded-lg">
<ChildIcon child={c} />
<span className="absolute bottom-0 right-0 m-1 font-medium text-gray-700 dark:text-gray-500">
{c.folder?.childCount}
</span>
</div>
)}
</div>
<div className="flex items-start justify-center space-x-2">
<span className="w-5 flex-shrink-0 text-center">
<ChildIcon child={c} />
</span>
<ChildName name={c.name} folder={Boolean(c.folder)} />
</div>
<div className="truncate text-center font-mono text-xs text-gray-700 dark:text-gray-500">
{formatModifiedDateTime(c.lastModifiedDateTime)}
</div>
</div>
)
}
const FolderGridLayout = ({
path,
folderChildren,
selected,
toggleItemSelected,
totalSelected,
toggleTotalSelected,
totalGenerating,
handleSelectedDownload,
folderGenerating,
handleSelectedPermalink,
handleFolderDownload,
toast,
}) => {
const clipboard = useClipboard()
const hashedToken = getStoredToken(path)
const { t } = useTranslation()
// Get item path from item name
const getItemPath = (name: string) => `${path === '/' ? '' : path}/${encodeURIComponent(name)}`
return (
<div className="rounded bg-white dark:bg-gray-900 dark:text-gray-100 shadow-sm">
<div className="flex items-center border-b border-gray-900/10 px-3 text-xs font-bold uppercase tracking-widest text-gray-600 dark:border-gray-500/30 dark:text-gray-400">
<div className="flex-1">{t('{{count}} item(s)', { count: folderChildren.length })}</div>
<div className="flex p-1.5 text-gray-700 dark:text-gray-400">
<Checkbox
checked={totalSelected}
onChange={toggleTotalSelected}
indeterminate={true}
title={t('Select all files')}
/>
<button
title={t('Copy selected files permalink')}
className="cursor-pointer rounded p-1.5 hover:bg-gray-300 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:bg-white dark:hover:bg-gray-600 disabled:dark:text-gray-600 disabled:hover:dark:bg-gray-900"
disabled={totalSelected === 0}
onClick={() => {
clipboard.copy(handleSelectedPermalink(getBaseUrl()))
toast.success(t('Copied selected files permalink.'))
}}
>
<FontAwesomeIcon icon={['far', 'copy']} size="lg" />
</button>
{totalGenerating ? (
<Downloading title={t('Downloading selected files, refresh page to cancel')} style="p-1.5" />
) : (
<button
title={t('Download selected files')}
className="cursor-pointer rounded p-1.5 hover:bg-gray-300 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:bg-white dark:hover:bg-gray-600 disabled:dark:text-gray-600 disabled:hover:dark:bg-gray-900"
disabled={totalSelected === 0}
onClick={handleSelectedDownload}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} size="lg" />
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 p-3 md:grid-cols-4">
{folderChildren.map((c: OdFolderChildren) => (
<div
key={c.id}
className="group relative overflow-hidden rounded transition-all duration-100 hover:bg-gray-100 dark:hover:bg-gray-850"
>
<div className="absolute top-0 right-0 z-10 m-1 rounded bg-white/50 py-0.5 opacity-0 transition-all duration-100 group-hover:opacity-100 dark:bg-gray-900/50">
{c.folder ? (
<div>
<span
title={t('Copy folder permalink')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => {
clipboard.copy(`${getBaseUrl()}${getItemPath(c.name)}`)
toast(t('Copied folder permalink.'), { icon: '👌' })
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
{folderGenerating[c.id] ? (
<Downloading title={t('Downloading folder, refresh page to cancel')} style="px-1.5 py-1" />
) : (
<span
title={t('Download folder')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={handleFolderDownload(getItemPath(c.name), c.id, c.name)}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</span>
)}
</div>
) : (
<div>
<span
title={t('Copy raw file permalink')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => {
clipboard.copy(
`${getBaseUrl()}/api/raw/?path=${getItemPath(c.name)}${
hashedToken ? `&odpt=${hashedToken}` : ''
}`
)
toast.success(t('Copied raw file permalink.'))
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
<a
title={t('Download file')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
href={`${getBaseUrl()}/api/raw/?path=${getItemPath(c.name)}${
hashedToken ? `&odpt=${hashedToken}` : ''
}`}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</a>
</div>
)}
</div>
<div
className={`${
selected[c.id] ? 'opacity-100' : 'opacity-0'
} absolute top-0 left-0 z-10 m-1 rounded bg-white/50 py-0.5 group-hover:opacity-100 dark:bg-gray-900/50`}
>
{!c.folder && !(c.name === '.password') && (
<Checkbox
checked={selected[c.id] ? 2 : 0}
onChange={() => toggleItemSelected(c.id)}
title={t('Select file')}
/>
)}
</div>
<Link href={getItemPath(c.name)} passHref>
<GridItem c={c} path={getItemPath(c.name)} />
</Link>
</div>
))}
</div>
</div>
)
}
export default FolderGridLayout
+184
View File
@@ -0,0 +1,184 @@
import type { OdFolderChildren } from '../types'
import Link from 'next/link'
import { FC } from 'react'
import { useClipboard } from 'use-clipboard-copy'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation } from 'next-i18next'
import { getBaseUrl } from '../utils/getBaseUrl'
import { humanFileSize, formatModifiedDateTime } from '../utils/fileDetails'
import { Downloading, Checkbox, ChildIcon, ChildName } from './FileListing'
import { getStoredToken } from '../utils/protectedRouteHandler'
const FileListItem: FC<{ fileContent: OdFolderChildren }> = ({ fileContent: c }) => {
return (
<div className="grid cursor-pointer grid-cols-10 items-center space-x-2 px-3 py-2.5">
<div className="col-span-10 flex items-center space-x-2 truncate md:col-span-6" title={c.name}>
<div className="w-5 flex-shrink-0 text-center">
<ChildIcon child={c} />
</div>
<ChildName name={c.name} folder={Boolean(c.folder)} />
</div>
<div className="col-span-3 hidden flex-shrink-0 font-mono text-sm text-gray-700 dark:text-gray-500 md:block">
{formatModifiedDateTime(c.lastModifiedDateTime)}
</div>
<div className="col-span-1 hidden flex-shrink-0 truncate font-mono text-sm text-gray-700 dark:text-gray-500 md:block">
{humanFileSize(c.size)}
</div>
</div>
)
}
const FolderListLayout = ({
path,
folderChildren,
selected,
toggleItemSelected,
totalSelected,
toggleTotalSelected,
totalGenerating,
handleSelectedDownload,
folderGenerating,
handleSelectedPermalink,
handleFolderDownload,
toast,
}) => {
const clipboard = useClipboard()
const hashedToken = getStoredToken(path)
const { t } = useTranslation()
// Get item path from item name
const getItemPath = (name: string) => `${path === '/' ? '' : path}/${encodeURIComponent(name)}`
return (
<div className="rounded bg-white dark:bg-gray-900 dark:text-gray-100 shadow-sm">
<div className="grid grid-cols-12 items-center space-x-2 border-b border-gray-900/10 px-3 dark:border-gray-500/30">
<div className="col-span-12 py-2 text-xs font-bold uppercase tracking-widest text-gray-600 dark:text-gray-300 md:col-span-6">
{t('Name')}
</div>
<div className="col-span-3 hidden text-xs font-bold uppercase tracking-widest text-gray-600 dark:text-gray-300 md:block">
{t('Last Modified')}
</div>
<div className="hidden text-xs font-bold uppercase tracking-widest text-gray-600 dark:text-gray-300 md:block">
{t('Size')}
</div>
<div className="hidden text-xs font-bold uppercase tracking-widest text-gray-600 dark:text-gray-300 md:block">
{t('Actions')}
</div>
<div className="hidden text-xs font-bold uppercase tracking-widest text-gray-600 dark:text-gray-300 md:block">
<div className="hidden p-1.5 text-gray-700 dark:text-gray-400 md:flex">
<Checkbox
checked={totalSelected}
onChange={toggleTotalSelected}
indeterminate={true}
title={t('Select files')}
/>
<button
title={t('Copy selected files permalink')}
className="cursor-pointer rounded p-1.5 hover:bg-gray-300 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:bg-white dark:hover:bg-gray-600 disabled:dark:text-gray-600 disabled:hover:dark:bg-gray-900"
disabled={totalSelected === 0}
onClick={() => {
clipboard.copy(handleSelectedPermalink(getBaseUrl()))
toast.success(t('Copied selected files permalink.'))
}}
>
<FontAwesomeIcon icon={['far', 'copy']} size="lg" />
</button>
{totalGenerating ? (
<Downloading title={t('Downloading selected files, refresh page to cancel')} style="p-1.5" />
) : (
<button
title={t('Download selected files')}
className="cursor-pointer rounded p-1.5 hover:bg-gray-300 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:bg-white dark:hover:bg-gray-600 disabled:dark:text-gray-600 disabled:hover:dark:bg-gray-900"
disabled={totalSelected === 0}
onClick={handleSelectedDownload}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} size="lg" />
</button>
)}
</div>
</div>
</div>
{folderChildren.map((c: OdFolderChildren) => (
<div
className="grid grid-cols-12 transition-all duration-100 hover:bg-gray-100 dark:hover:bg-gray-850"
key={c.id}
>
<Link
href={`${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`}
passHref
className="col-span-12 md:col-span-10"
>
<FileListItem fileContent={c} />
</Link>
{c.folder ? (
<div className="hidden p-1.5 text-gray-700 dark:text-gray-400 md:flex">
<span
title={t('Copy folder permalink')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => {
clipboard.copy(`${getBaseUrl()}${`${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`}`)
toast(t('Copied folder permalink.'), { icon: '👌' })
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
{folderGenerating[c.id] ? (
<Downloading title={t('Downloading folder, refresh page to cancel')} style="px-1.5 py-1" />
) : (
<span
title={t('Download folder')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => {
const p = `${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`
handleFolderDownload(p, c.id, c.name)()
}}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</span>
)}
</div>
) : (
<div className="hidden p-1.5 text-gray-700 dark:text-gray-400 md:flex">
<span
title={t('Copy raw file permalink')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => {
clipboard.copy(
`${getBaseUrl()}/api/raw/?path=${getItemPath(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`
)
toast.success(t('Copied raw file permalink.'))
}}
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
<a
title={t('Download file')}
className="cursor-pointer rounded px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
href={`/api/raw/?path=${getItemPath(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</a>
</div>
)}
<div className="hidden p-1.5 text-gray-700 dark:text-gray-400 md:flex">
{!c.folder && !(c.name === '.password') && (
<Checkbox
checked={selected[c.id] ? 2 : 0}
onChange={() => toggleItemSelected(c.id)}
title={t('Select file')}
/>
)}
</div>
</div>
))}
</div>
)
}
export default FolderListLayout
+18
View File
@@ -0,0 +1,18 @@
import config from '../../config/site.config'
const createFooterMarkup = () => {
return {
__html: config.footer,
}
}
const Footer = () => {
return (
<div
className="w-full border-t border-gray-900/10 p-4 text-center text-xs font-medium text-gray-400 dark:border-gray-500/30"
dangerouslySetInnerHTML={createFooterMarkup()}
></div>
)
}
export default Footer
+43
View File
@@ -0,0 +1,43 @@
import Image from 'next/image'
import { Trans } from 'next-i18next'
const FourOhFour: React.FC<{ errorMsg: string }> = ({ errorMsg }) => {
return (
<div className="my-12">
<div className="mx-auto w-1/3">
<Image src="/images/fabulous-rip-2.png" alt="404" width={912} height={912} priority />
</div>
<div className="mx-auto mt-6 max-w-xl text-gray-500">
<div className="mb-8 text-xl font-bold">
<Trans>
{/* eslint-disable-next-line react/no-unescaped-entities */}
Oops, that's a <span className="underline decoration-red-500 decoration-wavy">four-oh-four</span>.
</Trans>
</div>
<div className="mb-4 overflow-hidden break-all rounded border border-gray-400/20 bg-gray-50 p-2 font-mono text-xs dark:bg-gray-800">
{errorMsg}
</div>
<div className="text-sm">
<Trans>
Press{' '}
<kbd className="rounded border border-gray-400/20 bg-gray-100 px-1 font-mono text-xs dark:bg-gray-800">
F12
</kbd>{' '}
and open devtools for more details, or seek help at{' '}
<a
className="text-blue-600 hover:text-blue-700 hover:underline"
href="https://github.com/spencerwooo/onedrive-vercel-index/discussions"
target="_blank"
rel="noopener noreferrer"
>
onedrive-vercel-index discussions
</a>
.
</Trans>
</div>
</div>
</div>
)
}
export default FourOhFour
+24
View File
@@ -0,0 +1,24 @@
const Loading: React.FC<{ loadingText: string }> = ({ loadingText }) => {
return (
<div className="flex items-center justify-center space-x-1 rounded py-32 dark:text-white">
<LoadingIcon className="mr-3 -ml-1 h-5 w-5 animate-spin" />
<div>{loadingText}</div>
</div>
)
}
// As there is no CSS-in-JS styling system, pass class list to override styles
export const LoadingIcon: React.FC<{ className?: string }> = ({ className }) => {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
}
export default Loading
+259
View File
@@ -0,0 +1,259 @@
import { NextRouter } from 'next/router'
import toast from 'react-hot-toast'
import JSZip from 'jszip'
import { useTranslation } from 'next-i18next'
import { fetcher } from '../utils/fetchWithSWR'
import { getStoredToken } from '../utils/protectedRouteHandler'
/**
* A loading toast component with file download progress support
* @param props
* @param props.router Next router instance, used for reloading the page
* @param props.progress Current downloading and compression progress (returned by jszip metadata)
*/
export function DownloadingToast({ router, progress }: { router: NextRouter; progress?: string }) {
const { t } = useTranslation()
return (
<div className="flex items-center space-x-2">
<div className="w-56">
<span>{progress ? t('Downloading {{progress}}%', { progress }) : t('Downloading selected files...')}</span>
<div className="relative mt-2">
<div className="flex h-1 overflow-hidden rounded bg-gray-100">
<div style={{ width: `${progress}%` }} className="bg-gray-500 text-white transition-all duration-100"></div>
</div>
</div>
</div>
<button
className="rounded bg-red-500 p-2 text-white hover:bg-red-400 focus:outline-none focus:ring focus:ring-red-300"
onClick={() => router.reload()}
>
{t('Cancel')}
</button>
</div>
)
}
// 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)
// 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 async function downloadMultipleFiles({
toastId,
router,
files,
folder,
}: {
toastId: string
router: NextRouter
files: { name: string; url: string }[]
folder?: string
}): Promise<void> {
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 => {
return r.blob()
})
)
})
// Create zip file and download it
const b = await zip.generateAsync({ type: 'blob' }, metadata => {
toast.loading(<DownloadingToast router={router} progress={metadata.percent.toFixed(0)} />, {
id: toastId,
})
})
downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' })
}
/**
* 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
*/
export async function downloadTreelikeMultipleFiles({
toastId,
router,
files,
basePath,
folder,
}: {
toastId: string
router: NextRouter
files: AsyncGenerator<{
name: string
url?: string
path: string
isFolder: boolean
}>
basePath: string
folder?: string
}): Promise<void> {
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(<DownloadingToast router={router} progress={metadata.percent.toFixed(0)} />, {
id: toastId,
})
})
downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' })
}
interface TraverseItem {
path: string
meta: any
isFolder: boolean
error?: { status: number; message: string }
}
/**
* One-shot concurrent top-down 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 top-down as concurrently as possible.
* Every time we visit a folder, we fetch and return meta of all its children.
* If folders have pagination, partically retrieved items are not returned immediately,
* but after all children of the folder have been successfully retrieved.
* If an error occurred in paginated fetching, all children will be dropped.
* @param path Folder to be traversed. The path should be cleaned in advance.
* @returns Array of items representing folders and files of traversed folder top-down and excluding root folder.
* Due to top-down, 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<TraverseItem, void, undefined> {
const hashedToken = getStoredToken(path)
// Generate the task passed to Promise.race to request a folder
const genTask = async (i: number, path: string, next?: string) => {
return {
i,
path,
data: await fetcher([
next ? `/api/?path=${path}&next=${next}` : `/api?path=${path}`,
hashedToken ?? undefined,
]).catch(error => ({ i, path, error })),
}
}
// Pool containing Promises of folder requests
let pool = [genTask(0, path)]
// Map as item buffer for folders with pagination
const buf: { [k: string]: TraverseItem[] } = {}
// filter(() => true) removes gaps in the array
while (pool.filter(() => true).length > 0) {
let info: { i: number; path: string; data: any }
try {
info = await Promise.race(pool.filter(() => true))
} catch (error: any) {
const { i, path, error: innerError } = error
// 4xx errors are identified as handleable errors
if (Math.floor(innerError.status / 100) === 4) {
delete pool[i]
yield {
path,
meta: {},
isFolder: true,
error: { status: innerError.status, message: innerError.message.error },
}
continue
} else {
throw error
}
}
const { i, path, data } = info
if (!data || !data.folder) {
throw new Error('Path is not folder')
}
delete pool[i]
const items = data.folder.value.map((c: any) => {
const p = `${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`
return { path: p, meta: c, isFolder: Boolean(c.folder) }
}) as TraverseItem[]
if (data.next) {
buf[path] = (buf[path] ?? []).concat(items)
// Append next page task to the pool at the end
const i = pool.length
pool[i] = genTask(i, path, data.next)
} else {
const allItems = (buf[path] ?? []).concat(items)
if (buf[path]) {
delete buf[path]
}
allItems
.filter(item => item.isFolder)
.forEach(item => {
// Append new folder tasks to the pool at the end
const i = pool.length
pool[i] = genTask(i, item.path)
})
yield* allItems
}
}
}
+203
View File
@@ -0,0 +1,203 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconName } from '@fortawesome/fontawesome-svg-core'
import { Dialog, Transition } from '@headlessui/react'
import toast, { Toaster } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
import Link from 'next/link'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { Fragment, useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import siteConfig from '../../config/site.config'
import SearchModal from './SearchModal'
import SwitchLang from './SwitchLang'
import useDeviceOS from '../utils/useDeviceOS'
const Navbar = () => {
const router = useRouter()
const os = useDeviceOS()
const [tokenPresent, setTokenPresent] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const openSearchBox = () => setSearchOpen(true)
useHotkeys(`${os === 'mac' ? 'meta' : 'ctrl'}+k`, e => {
openSearchBox()
e.preventDefault()
})
useEffect(() => {
const storedToken = () => {
for (const r of siteConfig.protectedRoutes) {
if (localStorage.hasOwnProperty(r)) {
return true
}
}
return false
}
setTokenPresent(storedToken())
}, [])
const { t } = useTranslation()
const clearTokens = () => {
setIsOpen(false)
siteConfig.protectedRoutes.forEach(r => {
localStorage.removeItem(r)
})
toast.success(t('Cleared all tokens'))
setTimeout(() => {
router.reload()
}, 1000)
}
return (
<div className="sticky top-0 z-[100] border-b border-gray-900/10 bg-white bg-opacity-80 backdrop-blur-md dark:border-gray-500/30 dark:bg-gray-900">
<Toaster />
<SearchModal searchOpen={searchOpen} setSearchOpen={setSearchOpen} />
<div className="mx-auto flex w-full items-center justify-between space-x-4 px-4 py-1">
<Link href="/" passHref className="flex items-center space-x-2 py-2 hover:opacity-80 dark:text-white md:p-2">
<Image src={siteConfig.icon} alt="icon" width="25" height="25" priority />
<span className="hidden font-bold sm:block">{siteConfig.title}</span>
</Link>
<div className="flex flex-1 items-center space-x-4 text-gray-700 md:flex-initial">
<button
className="flex flex-1 items-center justify-between rounded-lg bg-gray-100 px-2.5 py-1.5 hover:opacity-80 dark:bg-gray-800 dark:text-white md:w-48"
onClick={openSearchBox}
>
<div className="flex items-center space-x-2">
<FontAwesomeIcon className="h-4 w-4" icon="search" />
<span className="truncate text-sm font-medium">{t('Search ...')}</span>
</div>
<div className="hidden items-center space-x-1 md:flex">
<div className="rounded-lg bg-gray-200 px-2 py-1 text-xs font-medium dark:bg-gray-700">
{os === 'mac' ? '⌘' : 'Ctrl'}
</div>
<div className="rounded-lg bg-gray-200 px-2 py-1 text-xs font-medium dark:bg-gray-700">K</div>
</div>
</button>
<SwitchLang />
{siteConfig.links.length !== 0 &&
siteConfig.links.map((l: { name: string; link: string }) => (
<a
key={l.name}
href={l.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 hover:opacity-80 dark:text-white"
>
<FontAwesomeIcon icon={['fab', l.name.toLowerCase() as IconName]} />
<span className="hidden text-sm font-medium md:inline-block">
{
// Append link name comments here to add translations
// t('Weibo')
t(l.name)
}
</span>
</a>
))}
{siteConfig.email && (
<a href={siteConfig.email} className="flex items-center space-x-2 hover:opacity-80 dark:text-white">
<FontAwesomeIcon icon={['far', 'envelope']} />
<span className="hidden text-sm font-medium md:inline-block">{t('Email')}</span>
</a>
)}
{tokenPresent && (
<button
className="flex items-center space-x-2 hover:opacity-80 dark:text-white"
onClick={() => setIsOpen(true)}
>
<span className="hidden text-sm font-medium md:inline-block">{t('Logout')}</span>
<FontAwesomeIcon icon="sign-out-alt" />
</button>
)}
</div>
</div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" open={isOpen} onClose={() => setIsOpen(false)}>
<div className="min-h-screen px-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-50"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-50 dark:bg-gray-800" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block h-screen align-middle" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-50"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="my-8 inline-block w-full max-w-md transform overflow-hidden rounded-lg bg-white p-6 text-left align-middle transition-all dark:bg-gray-900">
<Dialog.Title className="text-lg font-bold text-gray-900 dark:text-gray-100">
{t('Clear all tokens?')}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{t('These tokens are used to authenticate yourself into password protected folders, ') +
t('clearing them means that you will need to re-enter the passwords again.')}
</p>
</div>
<div className="mt-4 max-h-32 overflow-y-scroll font-mono text-sm dark:text-gray-100">
{siteConfig.protectedRoutes.map((r, i) => (
<div key={i} className="flex items-center space-x-1">
<FontAwesomeIcon icon="key" />
<span className="truncate">{r}</span>
</div>
))}
</div>
<div className="mt-8 flex items-center justify-end">
<button
className="mr-3 inline-flex items-center justify-center space-x-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-400 focus:outline-none focus:ring focus:ring-blue-300"
onClick={() => setIsOpen(false)}
>
{t('Cancel')}
</button>
<button
className="inline-flex items-center justify-center space-x-2 rounded bg-red-500 px-4 py-2 text-white hover:bg-red-400 focus:outline-none focus:ring focus:ring-red-300"
onClick={() => clearTokens()}
>
<FontAwesomeIcon icon={['far', 'trash-alt']} />
<span>{t('Clear all')}</span>
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition>
</div>
)
}
export default Navbar
+261
View File
@@ -0,0 +1,261 @@
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 { useTranslation } from 'next-i18next'
import Link from 'next/link'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Dialog, Transition } from '@headlessui/react'
import type { OdDriveItem, OdSearchResult } from '../types'
import { LoadingIcon } from './Loading'
import { getFileIcon } from '../utils/getFileIcon'
import { fetcher } from '../utils/fetchWithSWR'
import siteConfig from '../../config/site.config'
/**
* 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)
// 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 absolutePath.length > 1 // solve https://github.com/spencerwooo/onedrive-vercel-index/issues/539
? absolutePath[1]
.split('/')
.map(p => encodeURIComponent(decodeURIComponent(p)))
.join('/')
: ''
}
/**
* 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<OdSearchResult>(`/api/search/?q=${q}`)
// Map parentReference to the absolute path of the search result
data.map(item => {
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 debouncedDriveItemSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000))
const results = useAsync(async () => {
if (query.length === 0) {
return []
} else {
return debouncedDriveItemSearch(query)
}
}, [query])
return {
query,
setQuery,
results,
}
}
function SearchResultItemTemplate({
driveItem,
driveItemPath,
itemDescription,
disabled,
}: {
driveItem: OdSearchResult[number]
driveItemPath: string
itemDescription: string
disabled: boolean
}) {
return (
<Link
href={driveItemPath}
passHref
className={`flex items-center space-x-4 border-b border-gray-400/30 px-4 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-850 ${
disabled ? 'pointer-events-none cursor-not-allowed' : 'cursor-pointer'
}`}
>
<FontAwesomeIcon icon={driveItem.file ? getFileIcon(driveItem.name) : ['far', 'folder']} />
<div>
<div className="text-sm font-medium leading-8">{driveItem.name}</div>
<div
className={`overflow-hidden truncate font-mono text-xs opacity-60 ${
itemDescription === 'Loading ...' && 'animate-pulse'
}`}
>
{itemDescription}
</div>
</div>
</Link>
)
}
function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) {
const { data, error }: SWRResponse<OdDriveItem, { status: number; message: any }> = useSWR(
[`/api/item/?id=${result.id}`],
fetcher
)
const { t } = useTranslation()
if (error) {
return (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={''}
itemDescription={typeof error.message?.error === 'string' ? error.message.error : JSON.stringify(error.message)}
disabled={true}
/>
)
}
if (!data) {
return (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={''}
itemDescription={t('Loading ...')}
disabled={true}
/>
)
}
const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.name)}`
return (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={driveItemPath}
itemDescription={decodeURIComponent(driveItemPath)}
disabled={false}
/>
)
}
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 <SearchResultItemLoadRemote result={result} />
} 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 (
<SearchResultItemTemplate
driveItem={result}
driveItemPath={result.path}
itemDescription={driveItemPath}
disabled={false}
/>
)
}
}
export default function SearchModal({
searchOpen,
setSearchOpen,
}: {
searchOpen: boolean
setSearchOpen: Dispatch<SetStateAction<boolean>>
}) {
const { query, setQuery, results } = useDriveItemSearch()
const { t } = useTranslation()
const closeSearchBox = () => {
setSearchOpen(false)
setQuery('')
}
return (
<Transition appear show={searchOpen} as={Fragment}>
<Dialog as="div" className="fixed inset-0 z-[200] overflow-y-auto" onClose={closeSearchBox}>
<div className="min-h-screen px-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-white/80 dark:bg-gray-800/80" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="my-12 inline-block w-full max-w-3xl transform overflow-hidden rounded border border-gray-400/30 text-left shadow-xl transition-all">
<Dialog.Title
as="h3"
className="flex items-center space-x-4 border-b border-gray-400/30 bg-gray-50 p-4 dark:bg-gray-800 dark:text-white"
>
<FontAwesomeIcon icon="search" className="h-4 w-4" />
<input
type="text"
id="search-box"
className="w-full bg-transparent focus:outline-none focus-visible:outline-none"
placeholder={t('Search ...')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="rounded-lg bg-gray-200 px-2 py-1 text-xs font-medium dark:bg-gray-700">ESC</div>
</Dialog.Title>
<div
className="max-h-[80vh] overflow-x-hidden overflow-y-scroll bg-white dark:bg-gray-900 dark:text-white"
onClick={closeSearchBox}
>
{results.loading && (
<div className="px-4 py-12 text-center text-sm font-medium">
<LoadingIcon className="svg-inline--fa mr-2 inline-block h-4 w-4 animate-spin" />
<span>{t('Loading ...')}</span>
</div>
)}
{results.error && (
<div className="px-4 py-12 text-center text-sm font-medium">
{t('Error: {{message}}', { message: results.error.message })}
</div>
)}
{results.result && (
<>
{results.result.length === 0 ? (
<div className="px-4 py-12 text-center text-sm font-medium">{t('Nothing here.')}</div>
) : (
results.result.map(result => <SearchResultItem key={result.id} result={result} />)
)}
</>
)}
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { Fragment } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Menu, Transition } from '@headlessui/react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { useCookies, withCookies } from 'react-cookie'
// https://headlessui.dev/react/menu#integrating-with-next-js
const CustomLink = ({ href, children, as, locale, ...props }): JSX.Element => {
return (
<Link href={href} as={as} locale={locale} {...props}>
{children}
</Link>
)
}
const localeText = (locale: string): string => {
switch (locale) {
case 'en':
return '🇬🇧 English'
case 'zh-CN':
return '🇨🇳 简体中文'
case 'hi':
return '🇮🇳 हिन्दी'
case 'tr-TR':
return '🇹🇷 Türkçe'
case 'zh-TW':
return '🇹🇼 繁體中文'
default:
return '🇬🇧 English'
}
}
const SwitchLang = () => {
const { locales, pathname, query, asPath } = useRouter()
const [_, setCookie] = useCookies(['NEXT_LOCALE'])
return (
<div className="relative">
<Menu>
<Menu.Button className="flex items-center space-x-1.5 hover:opacity-80 dark:text-white">
<FontAwesomeIcon className="h-4 w-4" icon="language" />
<FontAwesomeIcon className="h-3 w-3" icon="chevron-down" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items className="absolute top-0 right-0 z-20 mt-8 w-28 divide-y divide-gray-900 overflow-auto rounded border border-gray-900/10 bg-white py-1 shadow-lg focus:outline-none dark:border-gray-500/30 dark:bg-gray-900 dark:text-white">
{locales!.map(locale => (
<Menu.Item key={locale}>
<CustomLink
key={locale}
href={{ pathname, query }}
as={asPath}
locale={locale}
onClick={() => setCookie('NEXT_LOCALE', locale, { path: '/' })}
>
<div className="m-1 cursor-pointer rounded px-2 py-1 text-left text-sm font-medium hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-600/10 dark:hover:text-blue-400">
{localeText(locale)}
</div>
</CustomLink>
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
export default withCookies(SwitchLang)
+79
View File
@@ -0,0 +1,79 @@
import { Fragment } from 'react'
import { IconProp } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Listbox, Transition } from '@headlessui/react'
import { useTranslation } from 'next-i18next'
import useLocalStorage from '../utils/useLocalStorage'
export const layouts: Array<{ id: number; name: 'Grid' | 'List'; icon: IconProp }> = [
{ id: 1, name: 'List', icon: 'th-list' },
{ id: 2, name: 'Grid', icon: 'th' },
]
const SwitchLayout = () => {
const [preferredLayout, setPreferredLayout] = useLocalStorage('preferredLayout', layouts[0])
const { t } = useTranslation()
return (
<div className="relative w-24 flex-shrink-0 text-sm text-gray-600 dark:text-gray-300 md:w-28">
<Listbox value={preferredLayout} onChange={setPreferredLayout}>
<Listbox.Button className="relative w-full cursor-pointer rounded pl-4">
<span className="pointer-events-none flex items-center">
<FontAwesomeIcon className="mr-2 h-3 w-3" icon={preferredLayout.icon} />
<span>
{
// t('Grid')
// t('List')
t(preferredLayout.name)
}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon className="h-3 w-3" icon="chevron-down" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Listbox.Options className="absolute right-0 z-20 mt-1 w-32 overflow-auto rounded border border-gray-900/10 bg-white py-1 shadow-lg focus:outline-none dark:border-gray-500/30 dark:bg-gray-800">
{layouts.map(layout => (
<Listbox.Option
key={layout.id}
className={`${
layout.name === preferredLayout.name &&
'bg-blue-50 text-blue-700 dark:bg-blue-600/10 dark:text-blue-400'
} relative flex cursor-pointer select-none items-center py-1.5 pl-3 text-gray-600 hover:opacity-80 dark:text-gray-300`}
value={layout}
>
<FontAwesomeIcon className="mr-2 h-3 w-3" icon={layout.icon} />
<span className={layout.name === preferredLayout.name ? 'font-medium' : 'font-normal'}>
{
// t('Grid')
// t('List')
t(layout.name)
}
</span>
{layout.name === preferredLayout.name && (
<span className="absolute inset-y-0 right-3 flex items-center">
<FontAwesomeIcon className="h-3 w-3" icon="check" />
</span>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
</div>
)
}
export default SwitchLayout
+116
View File
@@ -0,0 +1,116 @@
import type { OdFileObject } from '../../types'
import { FC, useEffect, useRef, useState } from 'react'
import ReactAudioPlayer from 'react-audio-player'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/router'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
import { LoadingIcon } from '../Loading'
import { formatModifiedDateTime } from '../../utils/fileDetails'
import { getStoredToken } from '../../utils/protectedRouteHandler'
enum PlayerState {
Loading,
Ready,
Playing,
Paused,
}
const AudioPreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { t } = useTranslation()
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const rapRef = useRef<ReactAudioPlayer>(null)
const [playerStatus, setPlayerStatus] = useState(PlayerState.Loading)
const [playerVolume, setPlayerVolume] = useState(1)
// Render audio thumbnail, and also check for broken thumbnails
const thumbnail = `/api/thumbnail/?path=${asPath}&size=medium${hashedToken ? `&odpt=${hashedToken}` : ''}`
const [brokenThumbnail, setBrokenThumbnail] = useState(false)
useEffect(() => {
// Manually get the HTML audio element and set onplaying event.
// - As the default event callbacks provided by the React component does not guarantee playing state to be set
// - properly when the user seeks through the timeline or the audio is buffered.
const rap = rapRef.current?.audioEl.current
if (rap) {
rap.oncanplay = () => setPlayerStatus(PlayerState.Ready)
rap.onended = () => setPlayerStatus(PlayerState.Paused)
rap.onpause = () => setPlayerStatus(PlayerState.Paused)
rap.onplay = () => setPlayerStatus(PlayerState.Playing)
rap.onplaying = () => setPlayerStatus(PlayerState.Playing)
rap.onseeking = () => setPlayerStatus(PlayerState.Loading)
rap.onwaiting = () => setPlayerStatus(PlayerState.Loading)
rap.onerror = () => setPlayerStatus(PlayerState.Paused)
rap.onvolumechange = () => setPlayerVolume(rap.volume)
}
}, [])
return (
<>
<PreviewContainer>
<div className="flex flex-col space-y-4 md:flex-row md:space-x-4">
<div className="relative flex aspect-square w-full items-center justify-center rounded bg-gray-100 transition-all duration-75 dark:bg-gray-700 md:w-48">
<div
className={`absolute z-20 flex h-full w-full items-center justify-center transition-all duration-300 ${
playerStatus === PlayerState.Loading
? 'bg-white opacity-80 dark:bg-gray-800'
: 'bg-transparent opacity-0'
}`}
>
<LoadingIcon className="z-10 inline-block h-5 w-5 animate-spin" />
</div>
{!brokenThumbnail ? (
<div className="absolute m-4 rounded-full shadow-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={`h-full w-full rounded-full object-cover object-top ${
playerStatus === PlayerState.Playing ? 'animate-spin-slow' : ''
}`}
src={thumbnail}
alt={file.name}
onError={() => setBrokenThumbnail(true)}
/>
</div>
) : (
<FontAwesomeIcon
className={`z-10 h-5 w-5 ${playerStatus === PlayerState.Playing ? 'animate-spin' : ''}`}
icon="music"
size="2x"
/>
)}
</div>
<div className="flex w-full flex-col justify-between">
<div>
<div className="mb-2 font-medium">{file.name}</div>
<div className="mb-4 text-sm text-gray-500">
{t('Last modified:') + ' ' + formatModifiedDateTime(file.lastModifiedDateTime)}
</div>
</div>
<ReactAudioPlayer
className="h-11 w-full"
src={`/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
ref={rapRef}
controls
preload="auto"
volume={playerVolume}
/>
</div>
</div>
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
export default AudioPreview
+60
View File
@@ -0,0 +1,60 @@
import { FC } from 'react'
import { useTranslation } from 'next-i18next'
import useSystemTheme from 'react-use-system-theme'
import { useRouter } from 'next/router'
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
import { tomorrowNightEighties, tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/hljs'
import useFileContent from '../../utils/fetchOnMount'
import { getLanguageByFileName } from '../../utils/getPreviewType'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
const CodePreview: FC<{ file: any }> = ({ file }) => {
const { asPath } = useRouter()
const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath)
const theme = useSystemTheme('dark')
const { t } = useTranslation()
if (error) {
return (
<PreviewContainer>
<FourOhFour errorMsg={error} />
</PreviewContainer>
)
}
if (validating) {
return (
<>
<PreviewContainer>
<Loading loadingText={t('Loading file content...')} />
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
return (
<>
<PreviewContainer>
<SyntaxHighlighter
language={getLanguageByFileName(file.name)}
style={theme === 'dark' ? tomorrowNightEighties : tomorrow}
>
{content}
</SyntaxHighlighter>
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
export default CodePreview
+11
View File
@@ -0,0 +1,11 @@
export function PreviewContainer({ children }): JSX.Element {
return <div className="rounded bg-white p-3 dark:bg-gray-900 dark:text-white shadow-sm">{children}</div>
}
export function DownloadBtnContainer({ children }): JSX.Element {
return (
<div className="sticky bottom-0 left-0 right-0 z-10 rounded border-t border-gray-900/10 bg-white bg-opacity-80 p-2 backdrop-blur-md dark:border-gray-500/30 dark:bg-gray-900 shadow-sm">
{children}
</div>
)
}
@@ -0,0 +1,82 @@
import type { OdFileObject } from '../../types'
import { FC } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation } from 'next-i18next'
import { getFileIcon } from '../../utils/getFileIcon'
import { formatModifiedDateTime, humanFileSize } from '../../utils/fileDetails'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
const DefaultPreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { t } = useTranslation()
return (
<div>
<PreviewContainer>
<div className="items-center px-5 py-4 md:flex md:space-x-8">
<div className="rounded-lg border border-gray-900/10 px-8 py-20 text-center dark:border-gray-500/30">
<FontAwesomeIcon icon={getFileIcon(file.name, { video: Boolean(file.video) })} />
<div className="mt-6 text-sm font-medium line-clamp-3 md:w-28">{file.name}</div>
</div>
<div className="flex flex-col space-y-2 py-4 md:flex-1">
<div>
<div className="py-2 text-xs font-medium uppercase opacity-80">{t('Last modified')}</div>
<div>{formatModifiedDateTime(file.lastModifiedDateTime)}</div>
</div>
<div>
<div className="py-2 text-xs font-medium uppercase opacity-80">{t('File size')}</div>
<div>{humanFileSize(file.size)}</div>
</div>
<div>
<div className="py-2 text-xs font-medium uppercase opacity-80">{t('MIME type')}</div>
<div>{file.file?.mimeType ?? t('Unavailable')}</div>
</div>
<div>
<div className="py-2 text-xs font-medium uppercase opacity-80">{t('Hashes')}</div>
<table className="block w-full overflow-scroll whitespace-nowrap text-sm md:table">
<tbody>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
Quick XOR
</td>
<td className="whitespace-nowrap py-1 px-3 font-mono text-gray-500 dark:text-gray-400">
{file.file.hashes?.quickXorHash ?? t('Unavailable')}
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
SHA1
</td>
<td className="whitespace-nowrap py-1 px-3 font-mono text-gray-500 dark:text-gray-400">
{file.file.hashes?.sha1Hash ?? t('Unavailable')}
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
SHA256
</td>
<td className="whitespace-nowrap py-1 px-3 font-mono text-gray-500 dark:text-gray-400">
{file.file.hashes?.sha256Hash ?? t('Unavailable')}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</div>
)
}
export default DefaultPreview
+77
View File
@@ -0,0 +1,77 @@
import type { OdFileObject } from '../../types'
import { FC, useEffect, useRef, useState } from 'react'
import { ReactReader } from 'react-reader'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import Loading from '../Loading'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer } from './Containers'
import { getStoredToken } from '../../utils/protectedRouteHandler'
const EPUBPreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const [epubContainerWidth, setEpubContainerWidth] = useState(400)
const epubContainer = useRef<HTMLDivElement>(null)
useEffect(() => {
setEpubContainerWidth(epubContainer.current ? epubContainer.current.offsetWidth : 400)
}, [])
const [location, setLocation] = useState<string>()
const onLocationChange = (cfiStr: string) => setLocation(cfiStr)
const { t } = useTranslation()
// Fix for not valid epub files according to
// https://github.com/gerhardsletten/react-reader/issues/33#issuecomment-673964947
const fixEpub = rendition => {
const spineGet = rendition.book.spine.get.bind(rendition.book.spine)
rendition.book.spine.get = function (target: string) {
const targetStr = target as string
let t = spineGet(target)
while (t == null && targetStr.startsWith('../')) {
target = targetStr.substring(3)
t = spineGet(target)
}
return t
}
}
return (
<div>
<div
className="no-scrollbar flex w-full flex-col overflow-scroll rounded bg-white dark:bg-gray-900 md:p-3"
style={{ maxHeight: '90vh' }}
>
<div className="no-scrollbar w-full flex-1 overflow-scroll" ref={epubContainer} style={{ minHeight: '70vh' }}>
<div
style={{
position: 'absolute',
width: epubContainerWidth,
height: '70vh',
}}
>
<ReactReader
url={`/api/raw/?path=${asPath}${hashedToken ? '&odpt=' + hashedToken : ''}`}
getRendition={rendition => fixEpub(rendition)}
loadingView={<Loading loadingText={t('Loading EPUB ...')} />}
location={location}
locationChanged={onLocationChange}
epubInitOptions={{ openAs: 'epub' }}
epubOptions={{ flow: 'scrolled', allowPopups: true }}
/>
</div>
</div>
</div>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</div>
)
}
export default EPUBPreview
+33
View File
@@ -0,0 +1,33 @@
import type { OdFileObject } from '../../types'
import { FC } from 'react'
import { useRouter } from 'next/router'
import { PreviewContainer, DownloadBtnContainer } from './Containers'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { getStoredToken } from '../../utils/protectedRouteHandler'
const ImagePreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
return (
<>
<PreviewContainer>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="mx-auto"
src={`/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`}
alt={file.name}
width={file.image?.width}
height={file.image?.height}
/>
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
export default ImagePreview
+140
View File
@@ -0,0 +1,140 @@
import { FC, CSSProperties, ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import { useTranslation } from 'next-i18next'
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
import { tomorrowNight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'
import 'katex/dist/katex.min.css'
import useFileContent from '../../utils/fetchOnMount'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
const MarkdownPreview: FC<{
file: any
path: string
standalone?: boolean
}> = ({ file, path, standalone = true }) => {
// The parent folder of the markdown file, which is also the relative image folder
const parentPath = standalone ? path.substring(0, path.lastIndexOf('/')) : path
const { response: content, error, validating } = useFileContent(`/api/raw/?path=${parentPath}/${file.name}`, path)
const { t } = useTranslation()
// Check if the image is relative path instead of a absolute url
const isUrlAbsolute = (url: string | string[]) => url.indexOf('://') > 0 || url.indexOf('//') === 0
// Custom renderer:
const customRenderer = {
// img: to render images in markdown with relative file paths
img: ({
alt,
src,
title,
width,
height,
style,
}: {
alt?: string
src?: string
title?: string
width?: string | number
height?: string | number
style?: CSSProperties
}) => {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={alt}
src={isUrlAbsolute(src as string) ? src : `/api/?path=${parentPath}/${src}&raw=true`}
title={title}
width={width}
height={height}
style={style}
/>
)
},
// code: to render code blocks with react-syntax-highlighter
code({
className,
children,
inline,
...props
}: {
className?: string | undefined
children: ReactNode
inline?: boolean
}) {
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
)
}
const match = /language-(\w+)/.exec(className || '')
return (
<SyntaxHighlighter language={match ? match[1] : 'language-text'} style={tomorrowNight} PreTag="div" {...props}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
)
},
}
if (error) {
return (
<PreviewContainer>
<FourOhFour errorMsg={error} />
</PreviewContainer>
)
}
if (validating) {
return (
<>
<PreviewContainer>
<Loading loadingText={t('Loading file content...')} />
</PreviewContainer>
{standalone && (
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
)}
</>
)
}
return (
<div>
<PreviewContainer>
<div className="markdown-body">
{/* Using rehypeRaw to render HTML inside Markdown is potentially dangerous, use under safe environments. (#18) */}
<ReactMarkdown
// @ts-ignore
remarkPlugins={[remarkGfm, remarkMath]}
// The type error is introduced by caniuse-lite upgrade.
// Since type errors occur often in remark toolchain and the use is so common,
// ignoring it shoudld be safe enough.
// @ts-ignore
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={customRenderer}
>
{content}
</ReactMarkdown>
</div>
</PreviewContainer>
{standalone && (
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
)}
</div>
)
}
export default MarkdownPreview
+39
View File
@@ -0,0 +1,39 @@
import type { OdFileObject } from '../../types'
import { FC, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import Preview from 'preview-office-docs'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer } from './Containers'
import { getBaseUrl } from '../../utils/getBaseUrl'
import { getStoredToken } from '../../utils/protectedRouteHandler'
const OfficePreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const docContainer = useRef<HTMLDivElement>(null)
const [docContainerWidth, setDocContainerWidth] = useState(600)
const docUrl = encodeURIComponent(
`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`
)
useEffect(() => {
setDocContainerWidth(docContainer.current ? docContainer.current.offsetWidth : 600)
}, [])
return (
<div>
<div className="overflow-scroll" ref={docContainer} style={{ maxHeight: '90vh' }}>
<Preview url={docUrl} width={docContainerWidth.toString()} height="600" />
</div>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</div>
)
}
export default OfficePreview
+28
View File
@@ -0,0 +1,28 @@
import { useRouter } from 'next/router'
import { getBaseUrl } from '../../utils/getBaseUrl'
import { getStoredToken } from '../../utils/protectedRouteHandler'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import { DownloadBtnContainer } from './Containers'
const PDFEmbedPreview: React.FC<{ file: any }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const pdfPath = encodeURIComponent(
`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`
)
const url = `https://mozilla.github.io/pdf.js/web/viewer.html?file=${pdfPath}`
return (
<div>
<div className="w-full overflow-hidden rounded" style={{ height: '90vh' }}>
<iframe src={url} frameBorder="0" width="100%" height="100%"></iframe>
</div>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</div>
)
}
export default PDFEmbedPreview
+61
View File
@@ -0,0 +1,61 @@
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import DownloadButtonGroup from '../DownloadBtnGtoup'
import useFileContent from '../../utils/fetchOnMount'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
const TextPreview = ({ file }) => {
const { asPath } = useRouter()
const { t } = useTranslation()
const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath)
if (error) {
return (
<PreviewContainer>
<FourOhFour errorMsg={error} />
</PreviewContainer>
)
}
if (validating) {
return (
<>
<PreviewContainer>
<Loading loadingText={t('Loading file content...')} />
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
if (!content) {
return (
<>
<PreviewContainer>
<FourOhFour errorMsg={t('File is empty.')} />
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</>
)
}
return (
<div>
<PreviewContainer>
<pre className="overflow-x-scroll p-0 text-sm md:p-3">{content}</pre>
</PreviewContainer>
<DownloadBtnContainer>
<DownloadButtonGroup />
</DownloadBtnContainer>
</div>
)
}
export default TextPreview
+66
View File
@@ -0,0 +1,66 @@
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import { DownloadButton } from '../DownloadBtnGtoup'
import useFileContent from '../../utils/fetchOnMount'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
const parseDotUrl = (content: string): string | undefined => {
return content
.split('\n')
.find(line => line.startsWith('URL='))
?.split('=')[1]
}
const TextPreview = ({ file }) => {
const { asPath } = useRouter()
const { t } = useTranslation()
const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath)
if (error) {
return (
<PreviewContainer>
<FourOhFour errorMsg={error} />
</PreviewContainer>
)
}
if (validating) {
return (
<PreviewContainer>
<Loading loadingText={t('Loading file content...')} />
</PreviewContainer>
)
}
if (!content) {
return (
<PreviewContainer>
<FourOhFour errorMsg={t('File is empty.')} />
</PreviewContainer>
)
}
return (
<div>
<PreviewContainer>
<pre className="overflow-x-scroll p-0 text-sm md:p-3">{content}</pre>
</PreviewContainer>
<DownloadBtnContainer>
<div className="flex justify-center">
<DownloadButton
onClickCallback={() => window.open(parseDotUrl(content) ?? '')}
btnColor="blue"
btnText={t('Open URL')}
btnIcon="external-link-alt"
btnTitle={t('Open URL{{url}}', { url: ' ' + parseDotUrl(content) ?? '' })}
/>
</div>
</DownloadBtnContainer>
</div>
)
}
export default TextPreview
+178
View File
@@ -0,0 +1,178 @@
import type { OdFileObject } from '../../types'
import { FC, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import axios from 'axios'
import toast from 'react-hot-toast'
import Plyr from 'plyr-react'
import { useAsync } from 'react-async-hook'
import { useClipboard } from 'use-clipboard-copy'
import { getBaseUrl } from '../../utils/getBaseUrl'
import { getExtension } from '../../utils/getFileIcon'
import { getStoredToken } from '../../utils/protectedRouteHandler'
import { DownloadButton } from '../DownloadBtnGtoup'
import { DownloadBtnContainer, PreviewContainer } from './Containers'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import CustomEmbedLinkMenu from '../CustomEmbedLinkMenu'
import 'plyr-react/plyr.css'
const VideoPlayer: FC<{
videoName: string
videoUrl: string
width?: number
height?: number
thumbnail: string
subtitle: string
isFlv: boolean
mpegts: any
}> = ({ videoName, videoUrl, width, height, thumbnail, subtitle, isFlv, mpegts }) => {
useEffect(() => {
// Really really hacky way to inject subtitles as file blobs into the video element
axios
.get(subtitle, { responseType: 'blob' })
.then(resp => {
const track = document.querySelector('track')
track?.setAttribute('src', URL.createObjectURL(resp.data))
})
.catch(() => {
console.log('Could not load subtitle.')
})
if (isFlv) {
const loadFlv = () => {
// Really hacky way to get the exposed video element from Plyr
const video = document.getElementById('plyr')
const flv = mpegts.createPlayer({ url: videoUrl, type: 'flv' })
flv.attachMediaElement(video)
flv.load()
}
loadFlv()
}
}, [videoUrl, isFlv, mpegts, subtitle])
// Common plyr configs, including the video source and plyr options
const plyrSource = {
type: 'video',
title: videoName,
poster: thumbnail,
tracks: [{ kind: 'captions', label: videoName, src: '', default: true }],
}
const plyrOptions: Plyr.Options = {
ratio: `${width ?? 16}:${height ?? 9}`,
fullscreen: { iosNative: true },
}
if (!isFlv) {
// If the video is not in flv format, we can use the native plyr and add sources directly with the video URL
plyrSource['sources'] = [{ src: videoUrl }]
}
return <Plyr id="plyr" source={plyrSource as Plyr.SourceInfo} options={plyrOptions} />
}
const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const clipboard = useClipboard()
const [menuOpen, setMenuOpen] = useState(false)
const { t } = useTranslation()
// OneDrive generates thumbnails for its video files, we pick the thumbnail with the highest resolution
const thumbnail = `/api/thumbnail/?path=${asPath}&size=large${hashedToken ? `&odpt=${hashedToken}` : ''}`
// We assume subtitle files are beside the video with the same name, only webvtt '.vtt' files are supported
const vtt = `${asPath.substring(0, asPath.lastIndexOf('.'))}.vtt`
const subtitle = `/api/raw/?path=${vtt}${hashedToken ? `&odpt=${hashedToken}` : ''}`
// We also format the raw video file for the in-browser player as well as all other players
const videoUrl = `/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`
const isFlv = getExtension(file.name) === 'flv'
const {
loading,
error,
result: mpegts,
} = useAsync(async () => {
if (isFlv) {
return (await import('mpegts.js')).default
}
}, [isFlv])
return (
<>
<CustomEmbedLinkMenu path={asPath} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<PreviewContainer>
{error ? (
<FourOhFour errorMsg={error.message} />
) : loading && isFlv ? (
<Loading loadingText={t('Loading FLV extension...')} />
) : (
<VideoPlayer
videoName={file.name}
videoUrl={videoUrl}
width={file.video?.width}
height={file.video?.height}
thumbnail={thumbnail}
subtitle={subtitle}
isFlv={isFlv}
mpegts={mpegts}
/>
)}
</PreviewContainer>
<DownloadBtnContainer>
<div className="flex flex-wrap justify-center gap-2">
<DownloadButton
onClickCallback={() => window.open(videoUrl)}
btnColor="blue"
btnText={t('Download')}
btnIcon="file-download"
/>
<DownloadButton
onClickCallback={() => {
clipboard.copy(`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`)
toast.success(t('Copied direct link to clipboard.'))
}}
btnColor="pink"
btnText={t('Copy direct link')}
btnIcon="copy"
/>
<DownloadButton
onClickCallback={() => setMenuOpen(true)}
btnColor="teal"
btnText={t('Customise link')}
btnIcon="pen"
/>
<DownloadButton
onClickCallback={() => window.open(`iina://weblink?url=${getBaseUrl()}${videoUrl}`)}
btnText="IINA"
btnImage="/players/iina.png"
/>
<DownloadButton
onClickCallback={() => window.open(`vlc://${getBaseUrl()}${videoUrl}`)}
btnText="VLC"
btnImage="/players/vlc.png"
/>
<DownloadButton
onClickCallback={() => window.open(`potplayer://${getBaseUrl()}${videoUrl}`)}
btnText="PotPlayer"
btnImage="/players/potplayer.png"
/>
<DownloadButton
onClickCallback={() => window.open(`nplayer-http://${window?.location.hostname ?? ''}${videoUrl}`)}
btnText="nPlayer"
btnImage="/players/nplayer.png"
/>
</div>
</DownloadBtnContainer>
</>
)
}
export default VideoPreview
+43
View File
@@ -0,0 +1,43 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import siteConfig from '../../config/site.config'
import Navbar from '../components/Navbar'
import FileListing from '../components/FileListing'
import Footer from '../components/Footer'
import Breadcrumb from '../components/Breadcrumb'
import SwitchLayout from '../components/SwitchLayout'
export default function Folders() {
const { query } = useRouter()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
<Head>
<title>{siteConfig.title}</title>
</Head>
<main className="flex w-full flex-1 flex-col bg-gray-50 dark:bg-gray-800">
<Navbar />
<div className="mx-auto w-full max-w-5xl py-4 sm:p-4">
<nav className="mb-4 flex items-center justify-between space-x-3 px-4 sm:px-0 sm:pl-1">
<Breadcrumb query={query} />
<SwitchLayout />
</nav>
<FileListing query={query} />
</div>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
}
}
+130
View File
@@ -0,0 +1,130 @@
import '@fortawesome/fontawesome-svg-core/styles.css'
import '../styles/globals.css'
import '../styles/markdown-github.css'
// Require had to be used to prevent SSR failure in Next.js
// Related discussion: https://github.com/FortAwesome/Font-Awesome/issues/19348
const { library, config } = require('@fortawesome/fontawesome-svg-core')
config.autoAddCss = false
import {
faFileImage,
faFilePdf,
faFileWord,
faFilePowerpoint,
faFileExcel,
faFileAudio,
faFileVideo,
faFileArchive,
faFileCode,
faFileAlt,
faFile,
faFolder,
faCopy,
faArrowAltCircleDown,
faTrashAlt,
faEnvelope,
faFlag,
faCheckCircle,
} from '@fortawesome/free-regular-svg-icons'
import {
faSearch,
faPen,
faCheck,
faPlus,
faMinus,
faCopy as faCopySolid,
faAngleRight,
faDownload,
faMusic,
faArrowLeft,
faArrowRight,
faFileDownload,
faUndo,
faBook,
faKey,
faSignOutAlt,
faCloud,
faChevronCircleDown,
faChevronDown,
faLink,
faExternalLinkAlt,
faExclamationCircle,
faExclamationTriangle,
faTh,
faThLarge,
faThList,
faHome,
faLanguage,
} from '@fortawesome/free-solid-svg-icons'
import * as Icons from '@fortawesome/free-brands-svg-icons'
import type { AppProps } from 'next/app'
import NextNProgress from 'nextjs-progressbar'
import { appWithTranslation } from 'next-i18next'
// import all brand icons with tree-shaking so all icons can be referenced in the app
const iconList = Object.keys(Icons)
.filter(k => k !== 'fab' && k !== 'prefix')
.map(icon => Icons[icon])
library.add(
faFileImage,
faFilePdf,
faFileWord,
faFilePowerpoint,
faFileExcel,
faFileAudio,
faFileVideo,
faFileArchive,
faFileCode,
faFileAlt,
faFile,
faFlag,
faFolder,
faMusic,
faArrowLeft,
faArrowRight,
faAngleRight,
faFileDownload,
faCopy,
faCopySolid,
faPlus,
faMinus,
faDownload,
faLink,
faUndo,
faBook,
faArrowAltCircleDown,
faKey,
faTrashAlt,
faSignOutAlt,
faEnvelope,
faCloud,
faChevronCircleDown,
faExternalLinkAlt,
faExclamationCircle,
faExclamationTriangle,
faHome,
faCheck,
faCheckCircle,
faSearch,
faChevronDown,
faTh,
faThLarge,
faThList,
faLanguage,
faPen,
...iconList
)
function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<NextNProgress height={1} color="rgb(156, 163, 175, 0.9)" options={{ showSpinner: false }} />
<Component {...pageProps} />
</>
)
}
export default appWithTranslation(MyApp)
+26
View File
@@ -0,0 +1,26 @@
import Document, { Head, Html, Main, NextScript } from 'next/document'
import siteConfig from '../../config/site.config'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta name="description" content="OneDrive Vercel Index" />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
{siteConfig.googleFontLinks.map(link => (
<link key={link} rel="stylesheet" href={link} />
))}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
+292
View File
@@ -0,0 +1,292 @@
import { posix as pathPosix } from 'path'
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import apiConfig from '../../../config/api.config'
import siteConfig from '../../../config/site.config'
import { revealObfuscatedToken } from '../../utils/oAuthHandler'
import { compareHashedToken } from '../../utils/protectedRouteHandler'
import { getOdAuthTokens, storeOdAuthTokens } from '../../utils/odAuthTokenStore'
import { runCorsMiddleware } from './raw'
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, path)
if (encodedPath === '/' || encodedPath === '') {
return ''
}
encodedPath = encodedPath.replace(/\/$/, '')
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<string> {
const { accessToken, refreshToken } = await getOdAuthTokens()
// Return in storage access token if it is still valid
if (typeof accessToken === 'string') {
console.log('Fetch access token from storage.')
return accessToken
}
// Return empty string if no refresh token is stored, which requires the application to be re-authenticated
if (typeof refreshToken !== 'string') {
console.log('No refresh token, return empty access token.')
return ''
}
// Fetch new access token with in storage refresh token
const body = new URLSearchParams()
body.append('client_id', apiConfig.clientId)
body.append('redirect_uri', apiConfig.redirectUri)
body.append('client_secret', clientSecret)
body.append('refresh_token', refreshToken)
body.append('grant_type', 'refresh_token')
const resp = await axios.post(apiConfig.authApi, body, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
if ('access_token' in resp.data && 'refresh_token' in resp.data) {
const { expires_in, access_token, refresh_token } = resp.data
await storeOdAuthTokens({
accessToken: access_token,
accessTokenExpiry: parseInt(expires_in),
refreshToken: refresh_token,
})
console.log('Fetch new access token with stored refresh token.')
return access_token
}
return ''
}
/**
* Match protected routes in site config to get path to required auth token
* @param path Path cleaned in advance
* @returns Path to required auth token. If not required, return empty string.
*/
export function getAuthTokenPath(path: string) {
// Ensure trailing slashes to compare paths component by component. Same for protectedRoutes.
// Since OneDrive ignores case, lower case before comparing. Same for protectedRoutes.
path = path.toLowerCase() + '/'
const protectedRoutes = siteConfig.protectedRoutes as string[]
let authTokenPath = ''
for (let r of protectedRoutes) {
if (typeof r !== 'string') continue
r = r.toLowerCase().replace(/\/$/, '') + '/'
if (path.startsWith(r)) {
authTokenPath = `${r}.password`
break
}
}
return authTokenPath
}
/**
* Handles protected route authentication:
* - Match the cleanPath against an array of user defined protected routes
* - If a match is found:
* - 1. Download the .password file stored inside the protected route and parse its contents
* - 2. Check if the od-protected-token header is present in the request
* - The request is continued only if these two contents are exactly the same
*
* @param cleanPath Sanitised directory path, used for matching whether route is protected
* @param accessToken OneDrive API access token
* @param req Next.js request object
* @param res Next.js response object
*/
export async function checkAuthRoute(
cleanPath: string,
accessToken: string,
odTokenHeader: string
): Promise<{ code: 200 | 401 | 404 | 500; message: string }> {
// Handle authentication through .password
const authTokenPath = getAuthTokenPath(cleanPath)
// Fetch password from remote file content
if (authTokenPath === '') {
return { code: 200, message: '' }
}
try {
const token = await axios.get(`${apiConfig.driveApi}/root${encodePath(authTokenPath)}`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
select: '@microsoft.graph.downloadUrl,file',
},
})
// Handle request and check for header 'od-protected-token'
const odProtectedToken = await axios.get(token.data['@microsoft.graph.downloadUrl'])
// console.log(odTokenHeader, odProtectedToken.data.trim())
if (
!compareHashedToken({
odTokenHeader: odTokenHeader,
dotPassword: odProtectedToken.data.toString(),
})
) {
return { code: 401, message: 'Password required.' }
}
} catch (error: any) {
// Password file not found, fallback to 404
if (error?.response?.status === 404) {
return { code: 404, message: "You didn't set a password." }
} else {
return { code: 500, message: 'Internal server error.' }
}
}
return { code: 200, message: 'Authenticated.' }
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// If method is POST, then the API is called by the client to store acquired tokens
if (req.method === 'POST') {
const { obfuscatedAccessToken, accessTokenExpiry, obfuscatedRefreshToken } = req.body
const accessToken = revealObfuscatedToken(obfuscatedAccessToken)
const refreshToken = revealObfuscatedToken(obfuscatedRefreshToken)
if (typeof accessToken !== 'string' || typeof refreshToken !== 'string') {
res.status(400).send('Invalid request body')
return
}
await storeOdAuthTokens({ accessToken, accessTokenExpiry, refreshToken })
res.status(200).send('OK')
return
}
// If method is GET, then the API is a normal request to the OneDrive API for files or folders
const { path = '/', raw = false, next = '', sort = '' } = req.query
// Set edge function caching for faster load times, check docs:
// https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
// Sometimes the path parameter is defaulted to '[...path]' which we need to handle
if (path === '[...path]') {
res.status(400).json({ error: 'No path specified.' })
return
}
// If the path is not a valid path, return 400
if (typeof path !== 'string') {
res.status(400).json({ error: 'Path query invalid.' })
return
}
// Besides normalizing and making absolute, trailing slashes are trimmed
const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path)).replace(/\/$/, '')
// Validate sort param
if (typeof sort !== 'string') {
res.status(400).json({ error: 'Sort query invalid.' })
return
}
const accessToken = await getAccessToken()
// Return error 403 if access_token is empty
if (!accessToken) {
res.status(403).json({ error: 'No access token.' })
return
}
// Handle protected routes authentication
const { code, message } = await checkAuthRoute(cleanPath, accessToken, req.headers['od-protected-token'] as string)
// Status code other than 200 means user has not authenticated yet
if (code !== 200) {
res.status(code).json({ error: message })
return
}
// If message is empty, then the path is not protected.
// Conversely, protected routes are not allowed to serve from cache.
if (message !== '') {
res.setHeader('Cache-Control', 'no-cache')
}
const requestPath = encodePath(cleanPath)
// Handle response from OneDrive API
const requestUrl = `${apiConfig.driveApi}/root${requestPath}`
// Whether path is root, which requires some special treatment
const isRoot = requestPath === ''
// Go for file raw download link, add CORS headers, and redirect to @microsoft.graph.downloadUrl
// (kept here for backwards compatibility, and cache headers will be reverted to no-cache)
if (raw) {
await runCorsMiddleware(req, res)
res.setHeader('Cache-Control', 'no-cache')
const { data } = await axios.get(requestUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
// OneDrive international version fails when only selecting the downloadUrl (what a stupid bug)
select: 'id,@microsoft.graph.downloadUrl',
},
})
if ('@microsoft.graph.downloadUrl' in data) {
res.redirect(data['@microsoft.graph.downloadUrl'])
} else {
res.status(404).json({ error: 'No download url found.' })
}
return
}
// Querying current path identity (file or folder) and follow up query childrens in folder
try {
const { data: identityData } = await axios.get(requestUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
select: 'name,size,id,lastModifiedDateTime,folder,file,video,image',
},
})
if ('folder' in identityData) {
const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/children`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
...{
select: 'name,size,id,lastModifiedDateTime,folder,file,video,image',
$top: siteConfig.maxItems,
},
...(next ? { $skipToken: next } : {}),
...(sort ? { $orderby: sort } : {}),
},
})
// Extract next page token from full @odata.nextLink
const nextPage = folderData['@odata.nextLink']
? folderData['@odata.nextLink'].match(/&\$skiptoken=(.+)/i)[1]
: null
// Return paging token if specified
if (nextPage) {
res.status(200).json({ folder: folderData, next: nextPage })
} else {
res.status(200).json({ folder: folderData })
}
return
}
res.status(200).json({ file: identityData })
return
} catch (error: any) {
res.status(error?.response?.code ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
return
}
}
+36
View File
@@ -0,0 +1,36 @@
import axios from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import { getAccessToken } from '.'
import apiConfig from '../../../config/api.config'
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
// Set edge function caching for faster load times, check docs:
// https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
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 ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
}
} else {
res.status(400).json({ error: 'Invalid driveItem ID.' })
}
return
}
+6
View File
@@ -0,0 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { default as rawFileHandler } from '../raw'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
rawFileHandler(req, res)
}
+93
View File
@@ -0,0 +1,93 @@
import { posix as pathPosix } from 'path'
import type { NextApiRequest, NextApiResponse } from 'next'
import axios, { AxiosResponseHeaders } from 'axios'
import Cors from 'cors'
import { driveApi, cacheControlHeader } from '../../../config/api.config'
import { encodePath, getAccessToken, checkAuthRoute } from '.'
// CORS middleware for raw links: https://nextjs.org/docs/api-routes/api-middlewares
export function runCorsMiddleware(req: NextApiRequest, res: NextApiResponse) {
const cors = Cors({ methods: ['GET', 'HEAD'] })
return new Promise((resolve, reject) => {
cors(req, res, result => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const accessToken = await getAccessToken()
if (!accessToken) {
res.status(403).json({ error: 'No access token.' })
return
}
const { path = '/', odpt = '', proxy = false } = req.query
// Sometimes the path parameter is defaulted to '[...path]' which we need to handle
if (path === '[...path]') {
res.status(400).json({ error: 'No path specified.' })
return
}
// If the path is not a valid path, return 400
if (typeof path !== 'string') {
res.status(400).json({ error: 'Path query invalid.' })
return
}
const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path))
// Handle protected routes authentication
const odTokenHeader = (req.headers['od-protected-token'] as string) ?? odpt
const { code, message } = await checkAuthRoute(cleanPath, accessToken, odTokenHeader)
// Status code other than 200 means user has not authenticated yet
if (code !== 200) {
res.status(code).json({ error: message })
return
}
// If message is empty, then the path is not protected.
// Conversely, protected routes are not allowed to serve from cache.
if (message !== '') {
res.setHeader('Cache-Control', 'no-cache')
}
await runCorsMiddleware(req, res)
try {
// Handle response from OneDrive API
const requestUrl = `${driveApi}/root${encodePath(cleanPath)}`
const { data } = await axios.get(requestUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
// OneDrive international version fails when only selecting the downloadUrl (what a stupid bug)
select: 'id,size,@microsoft.graph.downloadUrl',
},
})
if ('@microsoft.graph.downloadUrl' in data) {
// Only proxy raw file content response for files up to 4MB
if (proxy && 'size' in data && data['size'] < 4194304) {
const { headers, data: stream } = await axios.get(data['@microsoft.graph.downloadUrl'] as string, {
responseType: 'stream',
})
headers['Cache-Control'] = cacheControlHeader
// Send data stream as response
res.writeHead(200, headers as AxiosResponseHeaders)
stream.pipe(res)
} else {
res.redirect(data['@microsoft.graph.downloadUrl'])
}
} else {
res.status(404).json({ error: 'No download url found.' })
}
return
} catch (error: any) {
res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
return
}
}
+62
View File
@@ -0,0 +1,62 @@
import axios from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import { encodePath, getAccessToken } from '.'
import apiConfig from '../../../config/api.config'
import siteConfig from '../../../config/site.config'
/**
* Sanitize the search query
*
* @param query User search query, which may contain special characters
* @returns Sanitised query string, which:
* - encodes the '<' and '>' characters,
* - replaces '?' and '/' characters with ' ',
* - replaces ''' with ''''
* Reference: https://stackoverflow.com/questions/41491222/single-quote-escaping-in-microsoft-graph.
*/
function sanitiseQuery(query: string): string {
const sanitisedQuery = query
.replace(/'/g, "''")
.replace('<', ' &lt; ')
.replace('>', ' &gt; ')
.replace('?', ' ')
.replace('/', ' ')
return encodeURIComponent(sanitisedQuery)
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Get access token from storage
const accessToken = await getAccessToken()
// Query parameter from request
const { q: searchQuery = '' } = req.query
// Set edge function caching for faster load times, check docs:
// https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
if (typeof searchQuery === 'string') {
// 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)
} catch (error: any) {
res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
}
} else {
res.status(200).json([])
}
return
}
+75
View File
@@ -0,0 +1,75 @@
import type { OdThumbnail } from '../../types'
import { posix as pathPosix } from 'path'
import axios from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import { checkAuthRoute, encodePath, getAccessToken } from '.'
import apiConfig from '../../../config/api.config'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const accessToken = await getAccessToken()
if (!accessToken) {
res.status(403).json({ error: 'No access token.' })
return
}
// Get item thumbnails by its path since we will later check if it is protected
const { path = '', size = 'medium', odpt = '' } = req.query
// Set edge function caching for faster load times, if route is not protected, check docs:
// https://vercel.com/docs/concepts/functions/edge-caching
if (odpt === '') res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
// Check whether the size is valid - must be one of 'large', 'medium', or 'small'
if (size !== 'large' && size !== 'medium' && size !== 'small') {
res.status(400).json({ error: 'Invalid size' })
return
}
// Sometimes the path parameter is defaulted to '[...path]' which we need to handle
if (path === '[...path]') {
res.status(400).json({ error: 'No path specified.' })
return
}
// If the path is not a valid path, return 400
if (typeof path !== 'string') {
res.status(400).json({ error: 'Path query invalid.' })
return
}
const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path))
const { code, message } = await checkAuthRoute(cleanPath, accessToken, odpt as string)
// Status code other than 200 means user has not authenticated yet
if (code !== 200) {
res.status(code).json({ error: message })
return
}
// If message is empty, then the path is not protected.
// Conversely, protected routes are not allowed to serve from cache.
if (message !== '') {
res.setHeader('Cache-Control', 'no-cache')
}
const requestPath = encodePath(cleanPath)
// Handle response from OneDrive API
const requestUrl = `${apiConfig.driveApi}/root${requestPath}`
// Whether path is root, which requires some special treatment
const isRoot = requestPath === ''
try {
const { data } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/thumbnails`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
const thumbnailUrl = data.value && data.value.length > 0 ? (data.value[0] as OdThumbnail)[size].url : null
if (thumbnailUrl) {
res.redirect(thumbnailUrl)
} else {
res.status(400).json({ error: "The item doesn't have a valid thumbnail." })
}
} catch (error: any) {
res.status(error?.response?.status).json({ error: error?.response?.data ?? 'Internal server error.' })
}
return
}
+40
View File
@@ -0,0 +1,40 @@
import Head from 'next/head'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import siteConfig from '../../config/site.config'
import Navbar from '../components/Navbar'
import FileListing from '../components/FileListing'
import Footer from '../components/Footer'
import Breadcrumb from '../components/Breadcrumb'
import SwitchLayout from '../components/SwitchLayout'
export default function Home() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
<Head>
<title>{siteConfig.title}</title>
</Head>
<main className="flex w-full flex-1 flex-col bg-gray-50 dark:bg-gray-800">
<Navbar />
<div className="mx-auto w-full max-w-5xl py-4 sm:p-4">
<nav className="mb-4 flex items-center justify-between px-4 sm:px-0 sm:pl-1">
<Breadcrumb />
<SwitchLayout />
</nav>
<FileListing />
</div>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
}
}
@@ -0,0 +1,156 @@
import Head from 'next/head'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useTranslation, Trans } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import siteConfig from '../../../config/site.config'
import apiConfig from '../../../config/api.config'
import Navbar from '../../components/Navbar'
import Footer from '../../components/Footer'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export default function OAuthStep1() {
const router = useRouter()
const { t } = useTranslation()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
<Head>
<title>{t('OAuth Step 1 - {{title}}', { title: siteConfig.title })}</title>
</Head>
<main className="flex w-full flex-1 flex-col bg-gray-50 dark:bg-gray-800">
<Navbar />
<div className="mx-auto w-full max-w-5xl p-4">
<div className="rounded bg-white p-3 dark:bg-gray-900 dark:text-gray-100">
<div className="mx-auto w-52">
<Image src="/images/fabulous-fireworks.png" width={912} height={912} alt="fabulous fireworks" priority />
</div>
<h3 className="mb-4 text-center text-xl font-medium">
{t('Welcome to your new onedrive-vercel-index 🎉')}
</h3>
<h3 className="mt-4 mb-2 text-lg font-medium">{t('Step 1/3: Preparations')}</h3>
<p className="py-1 text-sm font-medium text-yellow-400">
<Trans>
<FontAwesomeIcon icon="exclamation-triangle" className="mr-1" /> If you have not specified a REDIS_URL
inside your Vercel env variable, go initialise one at{' '}
<a href="https://upstash.com/" target="_blank" rel="noopener noreferrer" className="underline">
Upstash
</a>
. Docs:{' '}
<a
href="https://docs.upstash.com/redis/howto/vercelintegration"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Vercel Integration - Upstash
</a>
.
</Trans>
</p>
<p className="py-1">
<Trans>
Authorisation is required as no valid{' '}
<code className="font-mono text-sm underline decoration-pink-600 decoration-wavy">access_token</code> or{' '}
<code className="font-mono text-sm underline decoration-green-600 decoration-wavy">refresh_token</code>{' '}
is present on this deployed instance. Check the following configurations before proceeding with
authorising onedrive-vercel-index with your own Microsoft account.
</Trans>
</p>
<div className="my-4 overflow-hidden">
<table className="min-w-full table-auto">
<tbody>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
CLIENT_ID
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.clientId}</code>
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
CLIENT_SECRET*
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.obfuscatedClientSecret}</code>
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
REDIRECT_URI
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.redirectUri}</code>
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
Auth API URL
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.authApi}</code>
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
Drive API URL
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.driveApi}</code>
</td>
</tr>
<tr className="border-y bg-white dark:border-gray-700 dark:bg-gray-900">
<td className="bg-gray-50 py-1 px-3 text-left text-xs font-medium uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-400">
API Scope
</td>
<td className="whitespace-nowrap py-1 px-3 text-gray-500 dark:text-gray-400">
<code className="font-mono text-sm">{apiConfig.scope}</code>
</td>
</tr>
</tbody>
</table>
</div>
<p className="py-1 text-sm font-medium">
<Trans>
<FontAwesomeIcon icon="exclamation-triangle" className="mr-1 text-yellow-400" /> If you see anything
missing or incorrect, you need to reconfigure{' '}
<code className="font-mono text-xs">/config/api.config.js</code> and redeploy this instance.
</Trans>
</p>
<div className="mb-2 mt-6 text-right">
<button
className="rounded-lg bg-gradient-to-r from-cyan-500 to-blue-500 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-bl focus:ring-4 focus:ring-cyan-300 dark:focus:ring-cyan-800"
onClick={() => {
router.push('/onedrive-vercel-index-oauth/step-2')
}}
>
<span>{t('Proceed to OAuth')}</span> <FontAwesomeIcon icon="arrow-right" />
</button>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
}
}
@@ -0,0 +1,150 @@
import Head from 'next/head'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation, Trans } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import siteConfig from '../../../config/site.config'
import Navbar from '../../components/Navbar'
import Footer from '../../components/Footer'
import { LoadingIcon } from '../../components/Loading'
import { extractAuthCodeFromRedirected, generateAuthorisationUrl } from '../../utils/oAuthHandler'
export default function OAuthStep2() {
const router = useRouter()
const [oAuthRedirectedUrl, setOAuthRedirectedUrl] = useState('')
const [authCode, setAuthCode] = useState('')
const [buttonLoading, setButtonLoading] = useState(false)
const { t } = useTranslation()
const oAuthUrl = generateAuthorisationUrl()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
<Head>
<title>{t('OAuth Step 2 - {{title}}', { title: siteConfig.title })}</title>
</Head>
<main className="flex w-full flex-1 flex-col bg-gray-50 dark:bg-gray-800">
<Navbar />
<div className="mx-auto w-full max-w-5xl p-4">
<div className="rounded bg-white p-3 dark:bg-gray-900 dark:text-gray-100">
<div className="mx-auto w-52">
<Image
src="/images/fabulous-come-back-later.png"
width={912}
height={912}
alt="fabulous come back later"
priority
/>
</div>
<h3 className="mb-4 text-center text-xl font-medium">
{t('Welcome to your new onedrive-vercel-index 🎉')}
</h3>
<h3 className="mt-4 mb-2 text-lg font-medium">{t('Step 2/3: Get authorisation code')}</h3>
<p className="py-1 text-sm font-medium text-red-400">
<Trans>
<FontAwesomeIcon icon="exclamation-circle" className="mr-1" /> If you are not the owner of this website,
stop now, as continuing with this process may expose your personal files in OneDrive.
</Trans>
</p>
<div
className="relative my-2 cursor-pointer rounded border border-gray-500/50 bg-gray-50 font-mono text-sm hover:opacity-80 dark:bg-gray-800"
onClick={() => {
window.open(oAuthUrl)
}}
>
<div className="absolute top-0 right-0 p-1 opacity-60">
<FontAwesomeIcon icon="external-link-alt" />
</div>
<pre className="overflow-x-auto whitespace-pre-wrap p-2">
<code>{oAuthUrl}</code>
</pre>
</div>
<p className="py-1">
<Trans>
The OAuth link for getting the authorisation code has been created. Click on the link above to get the{' '}
<b className="underline decoration-yellow-400 decoration-wavy">authorisation code</b>. Your browser will
{/* eslint-disable-next-line react/no-unescaped-entities */}
open a new tab to Microsoft's account login page. After logging in and authenticating with your
Microsoft account, you will be redirected to a blank page on localhost. Paste{' '}
<b className="underline decoration-teal-500 decoration-wavy">the entire redirected URL</b> down below.
</Trans>
</p>
<div className="my-4 mx-auto w-2/3 overflow-hidden rounded">
<Image src="/images/step-2-screenshot.png" width={1466} height={607} alt="step 2 screenshot" />
</div>
<input
className={`my-2 w-full flex-1 rounded border bg-gray-50 p-2 font-mono text-sm font-medium focus:outline-none focus:ring dark:bg-gray-800 dark:text-white ${
authCode
? 'border-green-500/50 focus:ring-green-500/30 dark:focus:ring-green-500/40'
: 'border-red-500/50 focus:ring-red-500/30 dark:focus:ring-red-500/40'
}`}
autoFocus
type="text"
placeholder="http://localhost/?code=M.R3_BAY.c0..."
value={oAuthRedirectedUrl}
onChange={e => {
setOAuthRedirectedUrl(e.target.value)
setAuthCode(extractAuthCodeFromRedirected(e.target.value))
}}
/>
<p className="py-1">{t('The authorisation code extracted is:')}</p>
<p className="my-2 overflow-hidden truncate rounded border border-gray-400/20 bg-gray-50 p-2 font-mono text-sm opacity-80 dark:bg-gray-800">
{authCode ?? <span className="animate-pulse">{t('Waiting for code...')}</span>}
</p>
<p>
{authCode
? t(' You can now proceed onto the next step: requesting your access token and refresh token.')
: t(' No valid code extracted.')}
</p>
<div className="mb-2 mt-6 text-right">
<button
className="rounded-lg bg-gradient-to-br from-green-500 to-cyan-400 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-bl focus:ring-4 focus:ring-green-200 disabled:cursor-not-allowed disabled:grayscale dark:focus:ring-green-800"
disabled={authCode === ''}
onClick={() => {
setButtonLoading(true)
router.push({ pathname: '/onedrive-vercel-index-oauth/step-3', query: { authCode } })
}}
>
{buttonLoading ? (
<>
<span>{t('Requesting tokens')}</span> <LoadingIcon className="ml-1 inline h-4 w-4 animate-spin" />
</>
) : (
<>
<span>{t('Get tokens')}</span> <FontAwesomeIcon icon="arrow-right" />
</>
)}
</button>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
}
}
@@ -0,0 +1,265 @@
import Head from 'next/head'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useTranslation, Trans } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import siteConfig from '../../../config/site.config'
import Navbar from '../../components/Navbar'
import Footer from '../../components/Footer'
import { getAuthPersonInfo, requestTokenWithAuthCode, sendTokenToServer } from '../../utils/oAuthHandler'
import { LoadingIcon } from '../../components/Loading'
export default function OAuthStep3({ accessToken, expiryTime, refreshToken, error, description, errorUri }) {
const router = useRouter()
const [expiryTimeLeft, setExpiryTimeLeft] = useState(expiryTime)
const { t } = useTranslation()
useEffect(() => {
if (!expiryTimeLeft) return
const intervalId = setInterval(() => {
setExpiryTimeLeft(expiryTimeLeft - 1)
}, 1000)
return () => clearInterval(intervalId)
}, [expiryTimeLeft])
const [buttonContent, setButtonContent] = useState(
<div>
<span>{t('Store tokens')}</span> <FontAwesomeIcon icon="key" />
</div>
)
const [buttonError, setButtonError] = useState(false)
const sendAuthTokensToServer = async () => {
setButtonError(false)
setButtonContent(
<div>
<span>{t('Storing tokens')}</span> <LoadingIcon className="ml-1 inline h-4 w-4 animate-spin" />
</div>
)
// verify identity of the authenticated user with the Microsoft Graph API
const { data, status } = await getAuthPersonInfo(accessToken)
if (status !== 200) {
setButtonError(true)
setButtonContent(
<div>
<span>{t('Error validating identify, restart')}</span> <FontAwesomeIcon icon="exclamation-circle" />
</div>
)
return
}
if (data.userPrincipalName !== siteConfig.userPrincipalName) {
setButtonError(true)
setButtonContent(
<div>
<span>{t('Do not pretend to be the site owner')}</span> <FontAwesomeIcon icon="exclamation-circle" />
</div>
)
return
}
await sendTokenToServer(accessToken, refreshToken, expiryTime)
.then(() => {
setButtonError(false)
setButtonContent(
<div>
<span>{t('Stored! Going home...')}</span> <FontAwesomeIcon icon="check" />
</div>
)
setTimeout(() => {
router.push('/')
}, 2000)
})
.catch(_ => {
setButtonError(true)
setButtonContent(
<div>
<span>{t('Error storing the token')}</span> <FontAwesomeIcon icon="exclamation-circle" />
</div>
)
})
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-white dark:bg-gray-900">
<Head>
<title>{t('OAuth Step 3 - {{title}}', { title: siteConfig.title })}</title>
</Head>
<main className="flex w-full flex-1 flex-col bg-gray-50 dark:bg-gray-800">
<Navbar />
<div className="mx-auto w-full max-w-5xl p-4">
<div className="rounded bg-white p-3 dark:bg-gray-900 dark:text-gray-100">
<div className="mx-auto w-52">
<Image
src="/images/fabulous-celebration.png"
width={912}
height={912}
alt="fabulous celebration"
priority
/>
</div>
<h3 className="mb-4 text-center text-xl font-medium">
{t('Welcome to your new onedrive-vercel-index 🎉')}
</h3>
<h3 className="mt-4 mb-2 text-lg font-medium">{t('Step 3/3: Get access and refresh tokens')}</h3>
{error ? (
<div>
<p className="py-1 font-medium text-red-500">
<FontAwesomeIcon icon="exclamation-circle" className="mr-2" />
<span>
{t('Whoops, looks like we got a problem: {{error}}.', {
// t('No auth code present')
error: t(error),
})}
</span>
</p>
<p className="my-2 whitespace-pre-line rounded border border-gray-400/20 bg-gray-50 p-2 font-mono text-sm opacity-80 dark:bg-gray-800">
{
// t('Where is the auth code? Did you follow step 2 you silly donut?')
t(description)
}
</p>
{errorUri && (
<p>
<Trans>
Check out{' '}
<a
href={errorUri}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline dark:text-blue-500"
>
{/* eslint-disable-next-line react/no-unescaped-entities */}
Microsoft's official explanation
</a>{' '}
on the error message.
</Trans>
</p>
)}
<div className="mb-2 mt-6 text-right">
<button
className="rounded-lg bg-gradient-to-br from-red-500 to-orange-400 px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-bl focus:ring-4 focus:ring-red-200 disabled:cursor-not-allowed disabled:grayscale dark:focus:ring-red-800"
onClick={() => {
router.push('/onedrive-vercel-index-oauth/step-1')
}}
>
<FontAwesomeIcon icon="arrow-left" /> <span>{t('Restart')}</span>
</button>
</div>
</div>
) : (
<div>
<p className="py-1 font-medium">{t('Success! The API returned what we needed.')}</p>
<ol className="py-1">
{accessToken && (
<li>
<FontAwesomeIcon icon={['far', 'check-circle']} className="text-green-500" />{' '}
<span>
{t('Acquired access_token: ')}
<code className="font-mono text-sm opacity-80">{`${accessToken.substring(0, 60)}...`}</code>
</span>
</li>
)}
{refreshToken && (
<li>
<FontAwesomeIcon icon={['far', 'check-circle']} className="text-green-500" />{' '}
<span>
{t('Acquired refresh_token: ')}
<code className="font-mono text-sm opacity-80">{`${refreshToken.substring(0, 60)}...`}</code>
</span>
</li>
)}
</ol>
<p className="py-1 text-sm font-medium text-teal-500">
<FontAwesomeIcon icon="exclamation-circle" className="mr-1" />{' '}
{t('These tokens may take a few seconds to populate after you click the button below. ') +
t('If you go back home and still see the welcome page telling you to re-authenticate, ') +
t('revisit home and do a hard refresh.')}
</p>
<p className="py-1">
{t(
'Final step, click the button below to store these tokens persistently before they expire after {{minutes}} minutes {{seconds}} seconds. ',
{
minutes: Math.floor(expiryTimeLeft / 60),
seconds: expiryTimeLeft - Math.floor(expiryTimeLeft / 60) * 60,
}
) +
t(
"Don't worry, after storing them, onedrive-vercel-index will take care of token refreshes and updates after your site goes live."
)}
</p>
<div className="mb-2 mt-6 text-right">
<button
className={`rounded-lg bg-gradient-to-br px-4 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-bl focus:ring-4 ${
buttonError
? 'from-red-500 to-orange-400 focus:ring-red-200 dark:focus:ring-red-800'
: 'from-green-500 to-teal-300 focus:ring-green-200 dark:focus:ring-green-800'
}`}
onClick={sendAuthTokensToServer}
>
{buttonContent}
</button>
</div>
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ query, locale }) {
const { authCode } = query
// Return if no auth code is present
if (!authCode) {
return {
props: {
error: 'No auth code present',
description: 'Where is the auth code? Did you follow step 2 you silly donut?',
...(await serverSideTranslations(locale, ['common'])),
},
}
}
const response = await requestTokenWithAuthCode(authCode)
// If error response, return invalid
if ('error' in response) {
return {
props: {
error: response.error,
description: response.errorDescription,
errorUri: response.errorUri,
...(await serverSideTranslations(locale, ['common'])),
},
}
}
const { expiryTime, accessToken, refreshToken } = response
return {
props: {
error: null,
expiryTime,
accessToken,
refreshToken,
...(await serverSideTranslations(locale, ['common'])),
},
}
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
.react-pdf__Page__canvas {
@apply mx-auto border border-gray-300/40 shadow;
}
.markdown-body ul {
@apply list-disc;
}
.markdown-body ol {
@apply list-decimal;
}
pre[class*='language-'],
code[class*='language-'] {
@apply font-mono !important;
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
// API response object for /api/?path=<path_to_file_or_folder>, this may return either a file or a folder.
// Pagination is also declared here with the 'next' parameter.
export type OdAPIResponse = { file?: OdFileObject; folder?: OdFolderObject; next?: string }
// A folder object returned from the OneDrive API. This contains the parameter 'value', which is an array of items
// inside the folder. The items may also be either files or folders.
export type OdFolderObject = {
'@odata.count': number
'@odata.context': string
'@odata.nextLink'?: string
value: Array<{
id: string
name: string
size: number
lastModifiedDateTime: string
file?: { mimeType: string; hashes: { quickXorHash?: string; sha1Hash?: string; sha256Hash?: string } }
folder?: { childCount: number; view: { sortBy: string; sortOrder: 'ascending'; viewType: 'thumbnails' } }
image?: OdImageFile
video?: OdVideoFile
}>
}
export type OdFolderChildren = OdFolderObject['value'][number]
// A file object returned from the OneDrive API. This object may contain 'video' if the file is a video.
export type OdFileObject = {
'@odata.context': string
name: string
size: number
id: string
lastModifiedDateTime: string
file: { mimeType: string; hashes: { quickXorHash: string; sha1Hash?: string; sha256Hash?: string } }
image?: OdImageFile
video?: OdVideoFile
}
// A representation of a OneDrive image file. Some images do not return a width and height, so types are optional.
export type OdImageFile = {
width?: number
height?: number
}
// A representation of a OneDrive video file. All fields are declared here, but we mainly use 'width' and 'height'.
export type OdVideoFile = {
width: number
height: number
duration: number
bitrate: number
frameRate: number
audioBitsPerSample: number
audioChannels: number
audioFormat: string
audioSamplesPerSecond: number
}
export type OdThumbnail = {
id: string
large: { height: number; width: number; url: string }
medium: { height: number; width: number; url: string }
small: { height: number; width: number; url: string }
}
// API response object for /api/search/?q=<query>. Likewise, this array of items may also contain either files or folders.
export type OdSearchResult = Array<{
id: string
name: string
file?: OdFileObject
folder?: OdFolderObject
path: string
parentReference: { id: string; name: string; path: string }
}>
// API response object for /api/item/?id={id}. This is primarily used for determining the path of the driveItem by ID.
export type OdDriveItem = {
'@odata.context': string
'@odata.etag': string
id: string
name: string
parentReference: { driveId: string; driveType: string; id: string; path: string }
}
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
import { getStoredToken } from './protectedRouteHandler'
/**
* Custom hook for axios to fetch raw file content on component mount
* @param fetchUrl The URL pointing to the raw file content
* @param path The path of the file, used for determining whether path is protected
*/
export default function useFileContent(
fetchUrl: string,
path: string
): { response: any; error: string; validating: boolean } {
const [response, setResponse] = useState('')
const [validating, setValidating] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const hashedToken = getStoredToken(path)
const url = fetchUrl + (hashedToken ? `&odpt=${hashedToken}` : '')
axios
// Using 'blob' as response type to get the response as a raw file blob, which is later parsed as a string.
// Axios defaults response parsing to JSON, which causes issues when parsing JSON files.
.get(url, { responseType: 'blob' })
.then(async res => setResponse(await res.data.text()))
.catch(e => setError(e.message))
.finally(() => setValidating(false))
}, [fetchUrl, path])
return { response, error, validating }
}
+57
View File
@@ -0,0 +1,57 @@
import axios from 'axios'
import useSWRInfinite from 'swr/infinite'
import type { OdAPIResponse } from '../types'
import { getStoredToken } from './protectedRouteHandler'
// Common axios fetch function for use with useSWR
export async function fetcher([url, token]: [url: string, token?: string]): Promise<any> {
try {
return (
await (token
? axios.get(url, {
headers: { 'od-protected-token': token },
})
: axios.get(url))
).data
} catch (err: any) {
throw { status: err.response.status, message: err.response.data }
}
}
/**
* 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: number, previousPageData: OdAPIResponse): (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]
}
// Disable auto-revalidate, these options are equivalent to useSWRImmutable
// https://swr.vercel.app/docs/revalidation#disable-automatic-revalidations
const revalidationOptions = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: true,
}
return useSWRInfinite(getNextKey, fetcher, revalidationOptions)
}
+28
View File
@@ -0,0 +1,28 @@
import dayjs from 'dayjs'
import siteConfig from '../../config/site.config'
/**
* Convert raw bits file/folder size into a human readable string
*
* @param size File or folder size, in raw bits
* @returns Human readable form of the file or folder size
*/
export const humanFileSize = (size: number) => {
if (size < 1024) return size + ' B'
const i = Math.floor(Math.log(size) / Math.log(1024))
const num = size / Math.pow(1024, i)
const round = Math.round(num)
const formatted = round < 10 ? num.toFixed(2) : round < 100 ? num.toFixed(1) : round
return `${formatted} ${'KMGTPEZY'[i - 1]}B`
}
/**
* Convert the last modified date time into locale friendly string
*
* @param lastModifedDateTime DateTime string in ISO format
* @returns Human readable form of the file or folder last modified date
*/
export const formatModifiedDateTime = (lastModifedDateTime: string) => {
return dayjs(lastModifedDateTime).format(siteConfig.datetimeFormat)
}
+10
View File
@@ -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 ''
}
+130
View File
@@ -0,0 +1,130 @@
import type { IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
const icons: { [key: string]: [IconPrefix, IconName] } = {
image: ['far', 'file-image'],
pdf: ['far', 'file-pdf'],
word: ['far', 'file-word'],
powerpoint: ['far', 'file-powerpoint'],
excel: ['far', 'file-excel'],
audio: ['far', 'file-audio'],
video: ['far', 'file-video'],
archive: ['far', 'file-archive'],
code: ['far', 'file-code'],
text: ['far', 'file-alt'],
file: ['far', 'file'],
markdown: ['fab', 'markdown'],
book: ['fas', 'book'],
link: ['fas', 'link'],
}
const extensions = {
gif: icons.image,
jpeg: icons.image,
jpg: icons.image,
png: icons.image,
heic: icons.image,
webp: icons.image,
pdf: icons.pdf,
doc: icons.word,
docx: icons.word,
ppt: icons.powerpoint,
pptx: icons.powerpoint,
xls: icons.excel,
xlsx: icons.excel,
aac: icons.audio,
mp3: icons.audio,
ogg: icons.audio,
flac: icons.audio,
oga: icons.audio,
opus: icons.audio,
m4a: icons.audio,
avi: icons.video,
flv: icons.video,
mkv: icons.video,
mp4: icons.video,
'7z': icons.archive,
bz2: icons.archive,
xz: icons.archive,
wim: icons.archive,
gz: icons.archive,
rar: icons.archive,
tar: icons.archive,
zip: icons.archive,
c: icons.code,
cpp: icons.code,
js: icons.code,
jsx: icons.code,
java: icons.code,
sh: icons.code,
cs: icons.code,
py: icons.code,
css: icons.code,
html: icons.code,
ts: icons.code,
tsx: icons.code,
rs: icons.code,
vue: icons.code,
json: icons.code,
yml: icons.code,
yaml: icons.code,
toml: icons.code,
txt: icons.text,
rtf: icons.text,
vtt: icons.text,
srt: icons.text,
log: icons.text,
diff: icons.text,
md: icons.markdown,
epub: icons.book,
mobi: icons.book,
azw3: icons.book,
url: icons.link,
}
/**
* To stop TypeScript complaining about indexing the object with a non-existent key
* https://dev.to/mapleleaf/indexing-objects-in-typescript-1cgi
*
* Fixed by ChatGPT with the upgrade of TypeScript 4.9
*
* @param obj Object with keys to index
* @param key The index key
* @returns Whether or not the key exists inside the object
*/
export function hasKey(obj: Record<string, any>, key: string): boolean {
return key in obj
}
export function getRawExtension(fileName: string): string {
return fileName.slice(((fileName.lastIndexOf('.') - 1) >>> 0) + 2)
}
export function getExtension(fileName: string): string {
return getRawExtension(fileName).toLowerCase()
}
export function getFileIcon(fileName: string, flags?: { video?: boolean }): [IconPrefix, IconName] {
const extension = getExtension(fileName)
let icon = hasKey(extensions, extension) ? extensions[extension] : icons.file
// Files with '.ts' extensions may be TypeScript files or TS Video files, we check for the flag 'video'
// to determine which icon to render for '.ts' files.
if (extension === 'ts') {
if (flags?.video) {
icon = icons.video
}
}
return icon
}
+123
View File
@@ -0,0 +1,123 @@
import { getExtension } from './getFileIcon'
export const preview = {
markdown: 'markdown',
image: 'image',
text: 'text',
pdf: 'pdf',
code: 'code',
video: 'video',
audio: 'audio',
office: 'ms-office',
epub: 'epub',
url: 'url',
}
export const extensions = {
gif: preview.image,
jpeg: preview.image,
jpg: preview.image,
png: preview.image,
webp: preview.image,
md: preview.markdown,
markdown: preview.markdown,
mdown: preview.markdown,
pdf: preview.pdf,
doc: preview.office,
docx: preview.office,
ppt: preview.office,
pptx: preview.office,
xls: preview.office,
xlsx: preview.office,
c: preview.code,
cpp: preview.code,
js: preview.code,
jsx: preview.code,
java: preview.code,
sh: preview.code,
cs: preview.code,
py: preview.code,
css: preview.code,
html: preview.code,
// typescript or video file, determined below
ts: preview.code,
tsx: preview.code,
rs: preview.code,
vue: preview.code,
json: preview.code,
yml: preview.code,
yaml: preview.code,
toml: preview.code,
txt: preview.text,
vtt: preview.text,
srt: preview.text,
log: preview.text,
diff: preview.text,
mp4: preview.video,
flv: preview.video,
webm: preview.video,
m3u8: preview.video,
mkv: preview.video,
mov: preview.video,
avi: preview.video, // won't work!
mp3: preview.audio,
m4a: preview.audio,
aac: preview.audio,
wav: preview.audio,
ogg: preview.audio,
oga: preview.audio,
opus: preview.audio,
flac: preview.audio,
epub: preview.epub,
url: preview.url,
}
export function getPreviewType(extension: string, flags?: { video?: boolean }): string | undefined {
let previewType = extensions[extension]
if (!previewType) {
return previewType
}
// Files with '.ts' extensions may be TypeScript files or TS Video files, we check for the flag 'video'
// to determine what preview renderer to use for '.ts' files.
if (extension === 'ts') {
if (flags?.video) {
previewType = preview.video
}
}
return previewType
}
export function getLanguageByFileName(filename: string): string {
const extension = getExtension(filename)
switch (extension) {
case 'ts':
case 'tsx':
return 'typescript'
case 'rs':
return 'rust'
case 'js':
case 'jsx':
return 'javascript'
case 'sh':
return 'shell'
case 'cs':
return 'csharp'
case 'py':
return 'python'
case 'yml':
return 'yaml'
default:
return extension
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Make path readable but still valid in URL (means the whole URL is still recognized as a URL)
* @param path Path. May be used as URL path or query value.
* @returns Readable but still valid path
*/
export function getReadablePath(path: string) {
path = path
.split('/')
.map(s => decodeURIComponent(s))
.map(s =>
Array.from(s)
.map(c => (isSafeChar(c) ? c : encodeURIComponent(c)))
.join('')
)
.join('/')
return path
}
// Check if the character is safe (means no need of percent-encoding)
function isSafeChar(c: string) {
if (c.charCodeAt(0) < 0x80) {
// ASCII
if (/^[a-zA-Z0-9\-._~]$/.test(c)) {
// RFC3986 unreserved chars
return true
} else if (/^[*:@,!]$/.test(c)) {
// Some extra pretty safe chars for URL path or query
// Ref: https://stackoverflow.com/a/42287988/11691878
return true
}
} else {
if (!/\s|\u180e/.test(c)) {
// Non-whitespace char. \u180e is missed in \s.
return true
}
}
return false
}
+111
View File
@@ -0,0 +1,111 @@
import axios from 'axios'
import CryptoJS from 'crypto-js'
import apiConfig from '../../config/api.config'
// Just a disguise to obfuscate required tokens (including but not limited to client secret,
// access tokens, and refresh tokens), used along with the following two functions
const AES_SECRET_KEY = 'onedrive-vercel-index'
export function obfuscateToken(token: string): string {
// Encrypt token with AES
const encrypted = CryptoJS.AES.encrypt(token, AES_SECRET_KEY)
return encrypted.toString()
}
export function revealObfuscatedToken(obfuscated: string): string {
// Decrypt SHA256 obfuscated token
const decrypted = CryptoJS.AES.decrypt(obfuscated, AES_SECRET_KEY)
return decrypted.toString(CryptoJS.enc.Utf8)
}
// Generate the Microsoft OAuth 2.0 authorization URL, used for requesting the authorisation code
export function generateAuthorisationUrl(): string {
const { clientId, redirectUri, authApi, scope } = apiConfig
const authUrl = authApi.replace('/token', '/authorize')
// Construct URL parameters for OAuth2
const params = new URLSearchParams()
params.append('client_id', clientId)
params.append('redirect_uri', redirectUri)
params.append('response_type', 'code')
params.append('scope', scope)
params.append('response_mode', 'query')
return `${authUrl}?${params.toString()}`
}
// The code returned from the Microsoft OAuth 2.0 authorization URL is a request URL with hostname
// http://localhost and URL parameter code. This function extracts the code from the request URL
export function extractAuthCodeFromRedirected(url: string): string {
// Return empty string if the url is not the defined redirect uri
if (!url.startsWith(apiConfig.redirectUri)) {
return ''
}
// New URL search parameter
const params = new URLSearchParams(url.split('?')[1])
return params.get('code') ?? ''
}
// After a successful authorisation, the code returned from the Microsoft OAuth 2.0 authorization URL
// will be used to request an access token. This function requests the access token with the authorisation code
// and returns the access token and refresh token on success.
export async function requestTokenWithAuthCode(
code: string
): Promise<
| { expiryTime: string; accessToken: string; refreshToken: string }
| { error: string; errorDescription: string; errorUri: string }
> {
const { clientId, redirectUri, authApi } = apiConfig
const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret)
// Construct URL parameters for OAuth2
const params = new URLSearchParams()
params.append('client_id', clientId)
params.append('redirect_uri', redirectUri)
params.append('client_secret', clientSecret)
params.append('code', code)
params.append('grant_type', 'authorization_code')
// Request access token
return axios
.post(authApi, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.then(resp => {
const { expires_in, access_token, refresh_token } = resp.data
return { expiryTime: expires_in, accessToken: access_token, refreshToken: refresh_token }
})
.catch(err => {
const { error, error_description, error_uri } = err.response.data
return { error, errorDescription: error_description, errorUri: error_uri }
})
}
// Verify the identity of the user with the access token and compare it with the userPrincipalName
// in the Microsoft Graph API. If the userPrincipalName matches, proceed with token storing.
export async function getAuthPersonInfo(accessToken: string) {
const profileApi = apiConfig.driveApi.replace('/drive', '')
return axios.get(profileApi, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
}
export async function sendTokenToServer(accessToken: string, refreshToken: string, expiryTime: string) {
return await axios.post(
'/api',
{
obfuscatedAccessToken: obfuscateToken(accessToken),
accessTokenExpiry: parseInt(expiryTime),
obfuscatedRefreshToken: obfuscateToken(refreshToken),
},
{
headers: {
'Content-Type': 'application/json',
},
}
)
}
+29
View File
@@ -0,0 +1,29 @@
import Redis from 'ioredis'
import siteConfig from '../../config/site.config'
// Persistent key-value store is provided by Redis, hosted on Upstash
// https://vercel.com/integrations/upstash
const kv = new Redis(process.env.REDIS_URL || '')
export async function getOdAuthTokens(): Promise<{ accessToken: unknown; refreshToken: unknown }> {
const accessToken = await kv.get(`${siteConfig.kvPrefix}access_token`)
const refreshToken = await kv.get(`${siteConfig.kvPrefix}refresh_token`)
return {
accessToken,
refreshToken,
}
}
export async function storeOdAuthTokens({
accessToken,
accessTokenExpiry,
refreshToken,
}: {
accessToken: string
accessTokenExpiry: number
refreshToken: string
}): Promise<void> {
await kv.set(`${siteConfig.kvPrefix}access_token`, accessToken, 'EX', accessTokenExpiry)
await kv.set(`${siteConfig.kvPrefix}refresh_token`, refreshToken)
}
+58
View File
@@ -0,0 +1,58 @@
import sha256 from 'crypto-js/sha256'
import siteConfig from '../../config/site.config'
// 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
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
export default function useDeviceOS(): string {
const [os, setOS] = useState('')
useEffect(() => {
const userAgent = window.navigator.userAgent
if (userAgent.indexOf('Windows') > -1) {
setOS('windows')
} else if (userAgent.indexOf('Mac OS') > -1) {
setOS('mac')
} else if (userAgent.indexOf('Linux') > -1) {
setOS('linux')
} else {
setOS('other')
}
}, [])
return os
}
+78
View File
@@ -0,0 +1,78 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
type SetValue<T> = Dispatch<SetStateAction<T>>
function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
// Get from local storage then
// parse stored json or return initialValue
const readValue = (): T => {
// Prevent build error "window is undefined" but keep keep working
if (typeof window === 'undefined') {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : initialValue
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error)
return initialValue
}
}
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue)
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = value => {
// Prevent build error "window is undefined" but keeps working
if (typeof window == 'undefined') {
console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`)
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(storedValue) : value
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue))
// Save state
setStoredValue(newValue)
// We dispatch a custom event so every useLocalStorage hook are notified
window.dispatchEvent(new Event('local-storage'))
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error)
}
}
useEffect(() => {
setStoredValue(readValue())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const handleStorageChange = () => {
setStoredValue(readValue())
}
// this only works for other documents, not the current one
window.addEventListener('storage', handleStorageChange)
// this is a custom event, triggered in writeValueToLocalStorage
window.addEventListener('local-storage', handleStorageChange)
return () => {
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('local-storage', handleStorageChange)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return [storedValue, setValue]
}
export default useLocalStorage