mirror of
https://github.com/Nezumi-2711/onedrive-vercel-index.git
synced 2026-09-22 05:32:01 +00:00
feat: use src/ dir for holding files
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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">
|
||||
​
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
​
|
||||
</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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'])),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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('<', ' < ')
|
||||
.replace('>', ' > ')
|
||||
.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'])),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
Vendored
+72
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user