├── .eslintrc ├── public ├── demo.png ├── favicon.ico ├── header.png ├── icons │ ├── 128.png │ ├── 256.png │ ├── 512.png │ └── 64.png ├── players │ ├── iina.png │ ├── vlc.png │ └── potplayer.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── images │ ├── fabulous-rip-2.png │ ├── fabulous-fireworks.png │ ├── step-2-screenshot.png │ ├── fabulous-celebration.png │ ├── fabulous-page-not-found.png │ ├── fabulous-come-back-later.png │ └── fabulous-wapmire-weekdays.png ├── site.webmanifest └── locales │ ├── zh-CN │ └── common.json │ └── en │ └── common.json ├── postcss.config.js ├── .prettierrc.js ├── next-env.d.ts ├── pages ├── api │ ├── name │ │ └── [name].ts │ ├── item.ts │ ├── proxy.ts │ ├── search.ts │ ├── raw.ts │ └── thumbnail.ts ├── _document.tsx ├── index.tsx ├── [...path].tsx ├── _app.tsx └── onedrive-vercel-index-oauth │ ├── step-2.tsx │ ├── step-1.tsx │ └── step-3.tsx ├── next.config.js ├── utils ├── getBaseUrl.ts ├── useDeviceOS.ts ├── odAuthTokenStore.ts ├── fileDetails.ts ├── getReadablePath.ts ├── fetchOnMount.ts ├── protectedRouteHandler.ts ├── fetchWithSWR.ts ├── useLocalStorage.ts ├── getPreviewType.ts ├── getFileIcon.ts └── oAuthHandler.ts ├── next-i18next.config.js ├── components ├── Footer.tsx ├── previews │ ├── Containers.tsx │ ├── ImagePreview.tsx │ ├── PDFPreview.tsx │ ├── OfficePreview.tsx │ ├── TextPreview.tsx │ ├── CodePreview.tsx │ ├── URLPreview.tsx │ ├── EPUBPreview.tsx │ ├── DefaultPreview.tsx │ ├── MarkdownPreview.tsx │ ├── AudioPreview.tsx │ └── VideoPreview.tsx ├── Loading.tsx ├── FourOhFour.tsx ├── Breadcrumb.tsx ├── Auth.tsx ├── SwitchLang.tsx ├── SwitchLayout.tsx ├── DownloadBtnGtoup.tsx ├── CustomEmbedLinkMenu.tsx ├── FolderListLayout.tsx ├── FolderGridLayout.tsx ├── Navbar.tsx ├── MultiFileDownloader.tsx └── SearchModal.tsx ├── i18next-parser.config.js ├── styles └── globals.css ├── tsconfig.json ├── .github └── FUNDING.yml ├── LICENSE ├── .gitignore ├── tailwind.config.js ├── package.json ├── config ├── api.config.js └── site.config.js ├── types └── index.d.ts └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /public/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/demo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/favicon.ico -------------------------------------------------------------------------------- /public/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/header.png -------------------------------------------------------------------------------- /public/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/icons/128.png -------------------------------------------------------------------------------- /public/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/icons/256.png -------------------------------------------------------------------------------- /public/icons/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/icons/512.png -------------------------------------------------------------------------------- /public/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/icons/64.png -------------------------------------------------------------------------------- /public/players/iina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/players/iina.png -------------------------------------------------------------------------------- /public/players/vlc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/players/vlc.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/favicon-32x32.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/players/potplayer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/players/potplayer.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/images/fabulous-rip-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-rip-2.png -------------------------------------------------------------------------------- /public/images/fabulous-fireworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-fireworks.png -------------------------------------------------------------------------------- /public/images/step-2-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/step-2-screenshot.png -------------------------------------------------------------------------------- /public/images/fabulous-celebration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-celebration.png -------------------------------------------------------------------------------- /public/images/fabulous-page-not-found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-page-not-found.png -------------------------------------------------------------------------------- /public/images/fabulous-come-back-later.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-come-back-later.png -------------------------------------------------------------------------------- /public/images/fabulous-wapmire-weekdays.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/onedrive-vercel-index/main/public/images/fabulous-wapmire-weekdays.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | arrowParens: "avoid", 4 | singleQuote: true, 5 | semi: false, 6 | plugins: [ 7 | require('prettier-plugin-tailwindcss') 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/api/name/[name].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { default as rawFileHandler } from '../raw' 3 | 4 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 5 | rawFileHandler(req, res) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { i18n } = require('./next-i18next.config') 2 | 3 | module.exports = { 4 | i18n, 5 | reactStrictMode: true, 6 | // Required by Next i18n with API routes, otherwise API routes 404 when fetching without trailing slash 7 | trailingSlash: true 8 | } 9 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /utils/getBaseUrl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extract the current web page's base url 3 | * @returns base url of the page 4 | */ 5 | export function getBaseUrl(): string { 6 | if (typeof window !== 'undefined') { 7 | return window.location.origin 8 | } 9 | return '' 10 | } 11 | -------------------------------------------------------------------------------- /next-i18next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | i18n: { 5 | defaultLocale: 'en', 6 | locales: ['en', 'zh-CN'] 7 | }, 8 | localePath: path.resolve('public/locales'), 9 | reloadOnPrerender: process.env.NODE_ENV === 'development', 10 | keySeparator: false, 11 | namespaceSeparator: false, 12 | pluralSeparator: '——', 13 | contextSeparator: '——' 14 | } 15 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import config from '../config/site.config' 2 | 3 | const createFooterMarkup = () => { 4 | return { 5 | __html: config.footer, 6 | } 7 | } 8 | 9 | const Footer = () => { 10 | return ( 11 |
15 | ) 16 | } 17 | 18 | export default Footer 19 | -------------------------------------------------------------------------------- /components/previews/Containers.tsx: -------------------------------------------------------------------------------- 1 | export function PreviewContainer({ children }): JSX.Element { 2 | return
{children}
3 | } 4 | 5 | export function DownloadBtnContainer({ children }): JSX.Element { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /utils/useDeviceOS.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export default function useDeviceOS(): string { 4 | const [os, setOS] = useState('') 5 | 6 | useEffect(() => { 7 | const userAgent = window.navigator.userAgent 8 | 9 | if (userAgent.indexOf('Windows') > -1) { 10 | setOS('windows') 11 | } else if (userAgent.indexOf('Mac OS') > -1) { 12 | setOS('mac') 13 | } else if (userAgent.indexOf('Linux') > -1) { 14 | setOS('linux') 15 | } else { 16 | setOS('other') 17 | } 18 | }, []) 19 | 20 | return os 21 | } 22 | -------------------------------------------------------------------------------- /i18next-parser.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const { i18n, localePath } = require('./next-i18next.config') 4 | 5 | module.exports = { 6 | createOldCatalogs: false, 7 | defaultNamespace: 'common', 8 | defaultValue: (lng, _ns, key) => (lng === i18n.defaultLocale ? key : ''), 9 | keySeparator: false, 10 | namespaceSeparator: false, 11 | pluralSeparator: '——', 12 | contextSeparator: '——', 13 | lineEnding: 'lf', 14 | locales: i18n.locales, 15 | output: path.join(localePath, '$LOCALE/$NAMESPACE.json'), 16 | input: ['**/*.{ts,tsx}', '!**/node_modules/**'], 17 | sort: true 18 | } 19 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | /* Chrome, Safari and Opera */ 7 | .no-scrollbar::-webkit-scrollbar { 8 | display: none; 9 | } 10 | 11 | .no-scrollbar { 12 | -ms-overflow-style: none; /* IE and Edge */ 13 | scrollbar-width: none; /* Firefox */ 14 | } 15 | } 16 | 17 | .react-pdf__Page__canvas { 18 | @apply mx-auto border border-gray-300/40 shadow; 19 | } 20 | 21 | .markdown-body ul { 22 | @apply list-disc; 23 | } 24 | .markdown-body ol { 25 | @apply list-decimal; 26 | } 27 | pre[class*='language-'], 28 | code[class*='language-'] { 29 | @apply font-mono !important; 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "noImplicitAny": false, 21 | "incremental": true 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: spencerwoo # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://afdian.net/@spencerwoo'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document' 2 | import siteConfig from '../config/site.config' 3 | 4 | class MyDocument extends Document { 5 | render() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | {siteConfig.googleFontLinks.map(link => ( 14 | 15 | ))} 16 | 17 | 18 |
19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | 26 | export default MyDocument 27 | -------------------------------------------------------------------------------- /utils/odAuthTokenStore.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | 3 | // Persistent key-value store is provided by Redis, hosted on Upstash 4 | // https://vercel.com/integrations/upstash 5 | const kv = new Redis(process.env.REDIS_URL) 6 | 7 | export async function getOdAuthTokens(): Promise<{ accessToken: unknown; refreshToken: unknown }> { 8 | const accessToken = await kv.get('access_token') 9 | const refreshToken = await kv.get('refresh_token') 10 | 11 | return { 12 | accessToken, 13 | refreshToken, 14 | } 15 | } 16 | 17 | export async function storeOdAuthTokens({ 18 | accessToken, 19 | accessTokenExpiry, 20 | refreshToken, 21 | }: { 22 | accessToken: string 23 | accessTokenExpiry: number 24 | refreshToken: string 25 | }): Promise { 26 | await kv.set('access_token', accessToken, 'ex', accessTokenExpiry) 27 | await kv.set('refresh_token', refreshToken) 28 | } 29 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | const Loading: React.FC<{ loadingText: string }> = ({ loadingText }) => { 2 | return ( 3 |
4 | 5 |
{loadingText}
6 |
7 | ) 8 | } 9 | 10 | // As there is no CSS-in-JS styling system, pass class list to override styles 11 | export const LoadingIcon: React.FC<{ className?: string }> = ({ className }) => { 12 | return ( 13 | 14 | 15 | 20 | 21 | ) 22 | } 23 | 24 | export default Loading 25 | -------------------------------------------------------------------------------- /utils/fileDetails.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | import siteConfig from '../config/site.config' 4 | 5 | /** 6 | * Convert raw bits file/folder size into a human readable string 7 | * 8 | * @param size File or folder size, in raw bits 9 | * @returns Human readable form of the file or folder size 10 | */ 11 | export const humanFileSize = (size: number) => { 12 | if (size < 1024) return size + ' B' 13 | const i = Math.floor(Math.log(size) / Math.log(1024)) 14 | const num = size / Math.pow(1024, i) 15 | const round = Math.round(num) 16 | const formatted = round < 10 ? num.toFixed(2) : round < 100 ? num.toFixed(1) : round 17 | return `${formatted} ${'KMGTPEZY'[i - 1]}B` 18 | } 19 | 20 | /** 21 | * Convert the last modified date time into locale friendly string 22 | * 23 | * @param lastModifedDateTime DateTime string in ISO format 24 | * @returns Human readable form of the file or folder last modified date 25 | */ 26 | export const formatModifiedDateTime = (lastModifedDateTime: string) => { 27 | return dayjs(lastModifedDateTime).format(siteConfig.datetimeFormat) 28 | } 29 | -------------------------------------------------------------------------------- /components/previews/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | 3 | import { FC } from 'react' 4 | import { useRouter } from 'next/router' 5 | 6 | import { PreviewContainer, DownloadBtnContainer } from './Containers' 7 | import DownloadButtonGroup from '../DownloadBtnGtoup' 8 | import { getStoredToken } from '../../utils/protectedRouteHandler' 9 | 10 | const ImagePreview: FC<{ file: OdFileObject }> = ({ file }) => { 11 | const { asPath } = useRouter() 12 | const hashedToken = getStoredToken(asPath) 13 | 14 | return ( 15 | <> 16 | 17 | {/* eslint-disable-next-line @next/next/no-img-element */} 18 | {file.name} 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export default ImagePreview 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Spencer Woo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/previews/PDFPreview.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { getBaseUrl } from '../../utils/getBaseUrl' 3 | import { getStoredToken } from '../../utils/protectedRouteHandler' 4 | import DownloadButtonGroup from '../DownloadBtnGtoup' 5 | import { DownloadBtnContainer } from './Containers' 6 | 7 | const PDFEmbedPreview: React.FC<{ file: any }> = ({ file }) => { 8 | const { asPath } = useRouter() 9 | const hashedToken = getStoredToken(asPath) 10 | 11 | // const url = `/api/proxy?url=${encodeURIComponent(...)}&inline=true` 12 | const pdfPath = encodeURIComponent( 13 | `${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}` 14 | ) 15 | const url = `https://mozilla.github.io/pdf.js/web/viewer.html?file=${pdfPath}` 16 | 17 | return ( 18 |
19 |
20 | 21 |
22 | 23 | 24 | 25 |
26 | ) 27 | } 28 | 29 | export default PDFEmbedPreview 30 | -------------------------------------------------------------------------------- /utils/getReadablePath.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make path readable but still valid in URL (means the whole URL is still recognized as a URL) 3 | * @param path Path. May be used as URL path or query value. 4 | * @returns Readable but still valid path 5 | */ 6 | export function getReadablePath(path: string) { 7 | path = path 8 | .split('/') 9 | .map(s => decodeURIComponent(s)) 10 | .map(s => 11 | Array.from(s) 12 | .map(c => (isSafeChar(c) ? c : encodeURIComponent(c))) 13 | .join('') 14 | ) 15 | .join('/') 16 | return path 17 | } 18 | 19 | // Check if the character is safe (means no need of percent-encoding) 20 | function isSafeChar(c: string) { 21 | if (c.charCodeAt(0) < 0x80) { 22 | // ASCII 23 | if (/^[a-zA-Z0-9\-._~]$/.test(c)) { 24 | // RFC3986 unreserved chars 25 | return true 26 | } else if (/^[*:@,!]$/.test(c)) { 27 | // Some extra pretty safe chars for URL path or query 28 | // Ref: https://stackoverflow.com/a/42287988/11691878 29 | return true 30 | } 31 | } else { 32 | if (!/\s|\u180e/.test(c)) { 33 | // Non-whitespace char. \u180e is missed in \s. 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # redis dump 37 | dump.rdb 38 | 39 | # General 40 | .DS_Store 41 | .AppleDouble 42 | .LSOverride 43 | 44 | # Icon must end with two \r 45 | Icon 46 | 47 | 48 | # Thumbnails 49 | ._* 50 | 51 | # Files that might appear in the root of a volume 52 | .DocumentRevisions-V100 53 | .fseventsd 54 | .Spotlight-V100 55 | .TemporaryItems 56 | .Trashes 57 | .VolumeIcon.icns 58 | .com.apple.timemachine.donotpresent 59 | 60 | # Directories potentially created on remote AFP share 61 | .AppleDB 62 | .AppleDesktop 63 | Network Trash Folder 64 | Temporary Items 65 | .apdisk 66 | 67 | .vscode/* 68 | 69 | # Local History for Visual Studio Code 70 | .history/ 71 | 72 | # Built Visual Studio Code Extensions 73 | *.vsix 74 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations' 3 | 4 | import siteConfig from '../config/site.config' 5 | import Navbar from '../components/Navbar' 6 | import FileListing from '../components/FileListing' 7 | import Footer from '../components/Footer' 8 | import Breadcrumb from '../components/Breadcrumb' 9 | import SwitchLayout from '../components/SwitchLayout' 10 | 11 | export default function Home() { 12 | return ( 13 |
14 | 15 | {siteConfig.title} 16 | 17 | 18 |
19 | 20 |
21 | 25 | 26 |
27 |
28 | 29 |
30 |
31 | ) 32 | } 33 | 34 | export async function getServerSideProps({ locale }) { 35 | return { 36 | props: { 37 | ...(await serverSideTranslations(locale, ['common'])), 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/api/item.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | import { getAccessToken } from '.' 5 | import apiConfig from '../../config/api.config' 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | // Get access token from storage 9 | const accessToken = await getAccessToken() 10 | 11 | // Get item details (specifically, its path) by its unique ID in OneDrive 12 | const { id = '' } = req.query 13 | 14 | // Set edge function caching for faster load times, check docs: 15 | // https://vercel.com/docs/concepts/functions/edge-caching 16 | res.setHeader('Cache-Control', apiConfig.cacheControlHeader) 17 | 18 | if (typeof id === 'string') { 19 | const itemApi = `${apiConfig.driveApi}/items/${id}` 20 | 21 | try { 22 | const { data } = await axios.get(itemApi, { 23 | headers: { Authorization: `Bearer ${accessToken}` }, 24 | params: { 25 | select: 'id,name,parentReference', 26 | }, 27 | }) 28 | res.status(200).json(data) 29 | } catch (error: any) { 30 | res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' }) 31 | } 32 | } else { 33 | res.status(400).json({ error: 'Invalid driveItem ID.' }) 34 | } 35 | return 36 | } 37 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | const colors = require('tailwindcss/colors') 3 | const siteConfig = require('./config/site.config') 4 | 5 | module.exports = { 6 | mode: 'jit', 7 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 8 | theme: { 9 | colors: { 10 | transparent: 'transparent', 11 | current: 'currentColor', 12 | black: colors.black, 13 | white: colors.white, 14 | gray: colors.zinc, 15 | red: colors.rose, 16 | yellow: colors.amber, 17 | green: colors.green, 18 | blue: colors.sky, 19 | indigo: colors.indigo, 20 | purple: colors.purple, 21 | pink: colors.pink, 22 | teal: colors.teal, 23 | cyan: colors.cyan, 24 | orange: colors.orange, 25 | }, 26 | extend: { 27 | fontFamily: { 28 | sans: [`"${siteConfig.googleFontSans}"`, '"Noto Sans SC"', ...defaultTheme.fontFamily.sans], 29 | mono: [`"${siteConfig.googleFontMono}"`, ...defaultTheme.fontFamily.mono] 30 | }, 31 | colors: { 32 | gray: { 33 | 850: '#222226' 34 | } 35 | }, 36 | animation: { 37 | 'spin-slow': 'spin 5s linear infinite', 38 | } 39 | } 40 | }, 41 | plugins: [ 42 | require('@tailwindcss/line-clamp'), 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /utils/fetchOnMount.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useEffect, useState } from 'react' 3 | import { getStoredToken } from './protectedRouteHandler' 4 | 5 | /** 6 | * Custom hook for axios to fetch raw file content on component mount 7 | * @param fetchUrl The URL pointing to the raw file content 8 | * @param path The path of the file, used for determining whether path is protected 9 | */ 10 | export default function useFileContent( 11 | fetchUrl: string, 12 | path: string 13 | ): { response: any; error: string; validating: boolean } { 14 | const [response, setResponse] = useState('') 15 | const [validating, setValidating] = useState(true) 16 | const [error, setError] = useState('') 17 | 18 | useEffect(() => { 19 | const hashedToken = getStoredToken(path) 20 | const url = fetchUrl + (hashedToken ? `&odpt=${hashedToken}` : '') 21 | 22 | axios 23 | // Using 'blob' as response type to get the response as a raw file blob, which is later parsed as a string. 24 | // Axios defaults response parsing to JSON, which causes issues when parsing JSON files. 25 | .get(url, { responseType: 'blob' }) 26 | .then(async res => setResponse(await res.data.text())) 27 | .catch(e => setError(e.message)) 28 | .finally(() => setValidating(false)) 29 | }, [fetchUrl, path]) 30 | return { response, error, validating } 31 | } 32 | -------------------------------------------------------------------------------- /components/previews/OfficePreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | import { FC, useEffect, useRef, useState } from 'react' 3 | import { useRouter } from 'next/router' 4 | 5 | import Preview from 'preview-office-docs' 6 | 7 | import DownloadButtonGroup from '../DownloadBtnGtoup' 8 | import { DownloadBtnContainer } from './Containers' 9 | import { getBaseUrl } from '../../utils/getBaseUrl' 10 | import { getStoredToken } from '../../utils/protectedRouteHandler' 11 | 12 | const OfficePreview: FC<{ file: OdFileObject }> = ({ file }) => { 13 | const { asPath } = useRouter() 14 | const hashedToken = getStoredToken(asPath) 15 | 16 | const docContainer = useRef(null) 17 | const [docContainerWidth, setDocContainerWidth] = useState(600) 18 | 19 | const docUrl = encodeURIComponent( 20 | `${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}` 21 | ) 22 | 23 | useEffect(() => { 24 | setDocContainerWidth(docContainer.current ? docContainer.current.offsetWidth : 600) 25 | }, []) 26 | 27 | return ( 28 |
29 |
30 | 31 |
32 | 33 | 34 | 35 |
36 | ) 37 | } 38 | 39 | export default OfficePreview 40 | -------------------------------------------------------------------------------- /pages/[...path].tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { useRouter } from 'next/router' 3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations' 4 | 5 | import siteConfig from '../config/site.config' 6 | import Navbar from '../components/Navbar' 7 | import FileListing from '../components/FileListing' 8 | import Footer from '../components/Footer' 9 | import Breadcrumb from '../components/Breadcrumb' 10 | import SwitchLayout from '../components/SwitchLayout' 11 | 12 | export default function Folders() { 13 | const { query } = useRouter() 14 | 15 | return ( 16 |
17 | 18 | {siteConfig.title} 19 | 20 | 21 |
22 | 23 |
24 | 28 | 29 |
30 |
31 | 32 |
33 |
34 | ) 35 | } 36 | 37 | export async function getServerSideProps({ locale }) { 38 | return { 39 | props: { 40 | ...(await serverSideTranslations(locale, ['common'])), 41 | }, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /components/FourOhFour.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { Trans } from 'next-i18next' 3 | 4 | const FourOhFour: React.FC<{ errorMsg: string }> = ({ errorMsg }) => { 5 | return ( 6 |
7 |
8 | 404 9 |
10 |
11 |
12 | 13 | {/* eslint-disable-next-line react/no-unescaped-entities */} 14 | Oops, that's a four-oh-four. 15 | 16 |
17 |
18 | {errorMsg} 19 |
20 |
21 | 22 | Press{' '} 23 | 24 | F12 25 | {' '} 26 | and open devtools for more details, or seek help at{' '} 27 | 33 | onedrive-vercel-index discussions 34 | 35 | . 36 | 37 |
38 |
39 |
40 | ) 41 | } 42 | 43 | export default FourOhFour 44 | -------------------------------------------------------------------------------- /pages/api/proxy.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | import apiConfig from '../../config/api.config' 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | // 'inline' is used for previewing PDF files inside the browser directly 8 | const { url, inline = false } = req.query 9 | 10 | if (!url || typeof url !== 'string') { 11 | res.status(400).json({ error: 'Bad request, URL is not valid.' }) 12 | return 13 | } 14 | 15 | // Only handle urls that start with OneDrive's own direct link (or SharePoint's): 16 | // https://public.*.files.1drv.com/y4m0G_0GPeS8AXGrux-lVV79eU1F38VbWxtCSi-8-aUkBLeZH1H6... 17 | const hostname = new URL(url).hostname 18 | if (hostname.match(new RegExp(apiConfig.directLinkRegex)) === null) { 19 | res 20 | .status(400) 21 | .json({ error: `URL forbidden, only OneDrive direct links that match ${apiConfig.directLinkRegex} are allowed.` }) 22 | return 23 | } 24 | 25 | const { headers, data: stream } = await axios.get(url as string, { 26 | responseType: 'stream', 27 | }) 28 | 29 | // Check if requested file is PDF based on content-type 30 | if (headers['content-type'] === 'application/pdf' && inline) { 31 | // Get filename from content-disposition header 32 | const filename = headers['content-disposition'].split(/filename[*]?=/)[1] 33 | // Remove original content-disposition header 34 | delete headers['content-disposition'] 35 | // Add new inline content-disposition header along with filename 36 | headers['content-disposition'] = `inline; filename*=UTF-8''${filename}` 37 | } 38 | 39 | // Send data stream as response 40 | res.writeHead(200, headers) 41 | stream.pipe(res) 42 | } 43 | -------------------------------------------------------------------------------- /components/previews/TextPreview.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useTranslation } from 'next-i18next' 3 | 4 | import FourOhFour from '../FourOhFour' 5 | import Loading from '../Loading' 6 | import DownloadButtonGroup from '../DownloadBtnGtoup' 7 | import useFileContent from '../../utils/fetchOnMount' 8 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 9 | 10 | const TextPreview = ({ file }) => { 11 | const { asPath } = useRouter() 12 | const { t } = useTranslation() 13 | 14 | const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath) 15 | if (error) { 16 | return ( 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | if (validating) { 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | if (!content) { 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | return ( 50 |
51 | 52 |
{content}
53 |
54 | 55 | 56 | 57 |
58 | ) 59 | } 60 | 61 | export default TextPreview 62 | -------------------------------------------------------------------------------- /utils/protectedRouteHandler.ts: -------------------------------------------------------------------------------- 1 | import sha256 from 'crypto-js/sha256' 2 | import siteConfig from '../config/site.config' 3 | 4 | // Hash password token with SHA256 5 | function encryptToken(token: string): string { 6 | return sha256(token).toString() 7 | } 8 | 9 | // Fetch stored token from localStorage and encrypt with SHA256 10 | export function getStoredToken(path: string): string | null { 11 | const storedToken = 12 | typeof window !== 'undefined' ? JSON.parse(localStorage.getItem(matchProtectedRoute(path)) as string) : '' 13 | return storedToken ? encryptToken(storedToken) : null 14 | } 15 | 16 | /** 17 | * Compares the hash of .password and od-protected-token header 18 | * @param odTokenHeader od-protected-token header (sha256 hashed token) 19 | * @param dotPassword non-hashed .password file 20 | * @returns whether the two hashes are the same 21 | */ 22 | export function compareHashedToken({ 23 | odTokenHeader, 24 | dotPassword, 25 | }: { 26 | odTokenHeader: string 27 | dotPassword: string 28 | }): boolean { 29 | return encryptToken(dotPassword.trim()) === odTokenHeader 30 | } 31 | /** 32 | * Match the specified route against a list of predefined routes 33 | * @param route directory path 34 | * @returns whether the directory is protected 35 | */ 36 | 37 | export function matchProtectedRoute(route: string): string { 38 | const protectedRoutes: string[] = siteConfig.protectedRoutes 39 | let authTokenPath = '' 40 | 41 | for (const r of protectedRoutes) { 42 | // protected route array could be empty 43 | if (r) { 44 | if ( 45 | route.startsWith( 46 | r 47 | .split('/') 48 | .map(p => encodeURIComponent(p)) 49 | .join('/') 50 | ) 51 | ) { 52 | authTokenPath = r 53 | break 54 | } 55 | } 56 | } 57 | return authTokenPath 58 | } 59 | -------------------------------------------------------------------------------- /components/previews/CodePreview.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { useTranslation } from 'next-i18next' 3 | import useSystemTheme from 'react-use-system-theme' 4 | import { useRouter } from 'next/router' 5 | 6 | import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter' 7 | import { tomorrowNightEighties, tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/hljs' 8 | 9 | import useFileContent from '../../utils/fetchOnMount' 10 | import { getLanguageByFileName } from '../../utils/getPreviewType' 11 | import FourOhFour from '../FourOhFour' 12 | import Loading from '../Loading' 13 | import DownloadButtonGroup from '../DownloadBtnGtoup' 14 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 15 | 16 | const CodePreview: FC<{ file: any }> = ({ file }) => { 17 | const { asPath } = useRouter() 18 | const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath) 19 | 20 | const theme = useSystemTheme('dark') 21 | const { t } = useTranslation() 22 | 23 | if (error) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | if (validating) { 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | return ( 44 | <> 45 | 46 | 50 | {content} 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | export default CodePreview 61 | -------------------------------------------------------------------------------- /components/previews/URLPreview.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useTranslation } from 'next-i18next' 3 | 4 | import FourOhFour from '../FourOhFour' 5 | import Loading from '../Loading' 6 | import { DownloadButton } from '../DownloadBtnGtoup' 7 | import useFileContent from '../../utils/fetchOnMount' 8 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 9 | 10 | const parseDotUrl = (content: string): string | undefined => { 11 | return content 12 | .split('\n') 13 | .find(line => line.startsWith('URL=')) 14 | ?.split('=')[1] 15 | } 16 | 17 | const TextPreview = ({ file }) => { 18 | const { asPath } = useRouter() 19 | const { t } = useTranslation() 20 | 21 | const { response: content, error, validating } = useFileContent(`/api/raw/?path=${asPath}`, asPath) 22 | if (error) { 23 | return ( 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | if (validating) { 31 | return ( 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | if (!content) { 39 | return ( 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | return ( 47 |
48 | 49 |
{content}
50 |
51 | 52 |
53 | window.open(parseDotUrl(content) ?? '')} 55 | btnColor="blue" 56 | btnText={t('Open URL')} 57 | btnIcon="external-link-alt" 58 | btnTitle={t('Open URL{{url}}', { url: ' ' + parseDotUrl(content) ?? '' })} 59 | /> 60 |
61 |
62 |
63 | ) 64 | } 65 | 66 | export default TextPreview 67 | -------------------------------------------------------------------------------- /utils/fetchWithSWR.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import useSWRInfinite from 'swr/infinite' 3 | 4 | import type { OdAPIResponse } from '../types' 5 | 6 | import { getStoredToken } from './protectedRouteHandler' 7 | 8 | // Common axios fetch function for use with useSWR 9 | export async function fetcher(url: string, token?: string): Promise { 10 | try { 11 | return ( 12 | await (token 13 | ? axios.get(url, { 14 | headers: { 'od-protected-token': token }, 15 | }) 16 | : axios.get(url)) 17 | ).data 18 | } catch (err: any) { 19 | throw { status: err.response.status, message: err.response.data } 20 | } 21 | } 22 | 23 | /** 24 | * Paging with useSWRInfinite + protected token support 25 | * @param path Current query directory path 26 | * @returns useSWRInfinite API 27 | */ 28 | export function useProtectedSWRInfinite(path: string = '') { 29 | const hashedToken = getStoredToken(path) 30 | 31 | /** 32 | * Next page infinite loading for useSWR 33 | * @param pageIdx The index of this paging collection 34 | * @param prevPageData Previous page information 35 | * @param path Directory path 36 | * @returns API to the next page 37 | */ 38 | function getNextKey(pageIndex: number, previousPageData: OdAPIResponse): (string | null)[] | null { 39 | // Reached the end of the collection 40 | if (previousPageData && !previousPageData.folder) return null 41 | 42 | // First page with no prevPageData 43 | if (pageIndex === 0) return [`/api/?path=${path}`, hashedToken] 44 | 45 | // Add nextPage token to API endpoint 46 | return [`/api/?path=${path}&next=${previousPageData.next}`, hashedToken] 47 | } 48 | 49 | // Disable auto-revalidate, these options are equivalent to useSWRImmutable 50 | // https://swr.vercel.app/docs/revalidation#disable-automatic-revalidations 51 | const revalidationOptions = { 52 | revalidateIfStale: false, 53 | revalidateOnFocus: false, 54 | revalidateOnReconnect: true, 55 | } 56 | return useSWRInfinite(getNextKey, fetcher, revalidationOptions) 57 | } 58 | -------------------------------------------------------------------------------- /pages/api/search.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | import { encodePath, getAccessToken } from '.' 5 | import apiConfig from '../../config/api.config' 6 | import siteConfig from '../../config/site.config' 7 | 8 | /** 9 | * Sanitize the search query 10 | * 11 | * @param query User search query, which may contain special characters 12 | * @returns Sanitised query string, which: 13 | * - encodes the '<' and '>' characters, 14 | * - replaces '?' and '/' characters with ' ', 15 | * - replaces ''' with '''' 16 | * Reference: https://stackoverflow.com/questions/41491222/single-quote-escaping-in-microsoft-graph. 17 | */ 18 | function sanitiseQuery(query: string): string { 19 | const sanitisedQuery = query 20 | .replace(/'/g, "''") 21 | .replace('<', ' < ') 22 | .replace('>', ' > ') 23 | .replace('?', ' ') 24 | .replace('/', ' ') 25 | return encodeURIComponent(sanitisedQuery) 26 | } 27 | 28 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 29 | // Get access token from storage 30 | const accessToken = await getAccessToken() 31 | 32 | // Query parameter from request 33 | const { q: searchQuery = '' } = req.query 34 | 35 | // Set edge function caching for faster load times, check docs: 36 | // https://vercel.com/docs/concepts/functions/edge-caching 37 | res.setHeader('Cache-Control', apiConfig.cacheControlHeader) 38 | 39 | if (typeof searchQuery === 'string') { 40 | // Construct Microsoft Graph Search API URL, and perform search only under the base directory 41 | const searchRootPath = encodePath('/') 42 | const encodedPath = searchRootPath === '' ? searchRootPath : searchRootPath + ':' 43 | 44 | const searchApi = `${apiConfig.driveApi}/root${encodedPath}/search(q='${sanitiseQuery(searchQuery)}')` 45 | 46 | try { 47 | const { data } = await axios.get(searchApi, { 48 | headers: { Authorization: `Bearer ${accessToken}` }, 49 | params: { 50 | select: 'id,name,file,folder,parentReference', 51 | top: siteConfig.maxItems, 52 | }, 53 | }) 54 | res.status(200).json(data.value) 55 | } catch (error: any) { 56 | res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' }) 57 | } 58 | } else { 59 | res.status(200).json([]) 60 | } 61 | return 62 | } 63 | -------------------------------------------------------------------------------- /components/Breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import type { ParsedUrlQuery } from 'querystring' 2 | 3 | import Link from 'next/link' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { useTranslation } from 'next-i18next' 6 | 7 | const HomeCrumb = () => { 8 | const { t } = useTranslation() 9 | 10 | return ( 11 | 12 | 13 | 14 | {t('Home')} 15 | 16 | 17 | ) 18 | } 19 | 20 | const Breadcrumb: React.FC<{ query?: ParsedUrlQuery }> = ({ query }) => { 21 | if (query) { 22 | const { path } = query 23 | if (Array.isArray(path)) { 24 | // We are rendering the path in reverse, so that the browser automatically scrolls to the end of the breadcrumb 25 | // https://stackoverflow.com/questions/18614301/keep-overflow-div-scrolled-to-bottom-unless-user-scrolls-up/18614561 26 | return ( 27 |
    28 | {path 29 | .slice(0) 30 | .reverse() 31 | .map((p: string, i: number) => ( 32 |
  1. 33 | 34 | encodeURIComponent(p)) 38 | .join('/')}`} 39 | passHref 40 | > 41 | 46 | {p} 47 | 48 | 49 |
  2. 50 | ))} 51 |
  3. 52 | 53 |
  4. 54 |
55 | ) 56 | } 57 | } 58 | 59 | return ( 60 |
61 | 62 |
63 | ) 64 | } 65 | 66 | export default Breadcrumb 67 | -------------------------------------------------------------------------------- /components/Auth.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | 3 | import Image from 'next/image' 4 | import { useRouter } from 'next/router' 5 | import { FC, useState } from 'react' 6 | import { useTranslation } from 'next-i18next' 7 | 8 | import { matchProtectedRoute } from '../utils/protectedRouteHandler' 9 | import useLocalStorage from '../utils/useLocalStorage' 10 | 11 | const Auth: FC<{ redirect: string }> = ({ redirect }) => { 12 | const authTokenPath = matchProtectedRoute(redirect) 13 | 14 | const router = useRouter() 15 | const [token, setToken] = useState('') 16 | const [_, setPersistedToken] = useLocalStorage(authTokenPath, '') 17 | 18 | const { t } = useTranslation() 19 | 20 | return ( 21 |
22 |
23 | authenticate 24 |
25 |
{t('Enter Password')}
26 | 27 |

28 | {t('This route (the folder itself and the files inside) is password protected. ') + 29 | t('If you know the password, please enter it below.')} 30 |

31 | 32 |
33 | { 40 | setToken(e.target.value) 41 | }} 42 | onKeyPress={e => { 43 | if (e.key === 'Enter' || e.key === 'NumpadEnter') { 44 | setPersistedToken(token) 45 | router.reload() 46 | } 47 | }} 48 | /> 49 | 58 |
59 |
60 | ) 61 | } 62 | 63 | export default Auth 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onedrive-vercel-index", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier 'components/**/*.tsx' 'config/*.js' 'pages/**/*.{ts,tsx}' '{types,utils}/**/*.ts' --write", 11 | "extract": "i18next" 12 | }, 13 | "dependencies": { 14 | "@fortawesome/fontawesome-svg-core": "^1.2.35", 15 | "@fortawesome/free-brands-svg-icons": "^5.15.4", 16 | "@fortawesome/free-regular-svg-icons": "^5.15.3", 17 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 18 | "@fortawesome/react-fontawesome": "^0.1.14", 19 | "@headlessui/react": "^1.4.0", 20 | "@tailwindcss/line-clamp": "^0.3.1", 21 | "awesome-debounce-promise": "^2.1.0", 22 | "axios": "^0.25.0", 23 | "cors": "^2.8.5", 24 | "crypto-js": "^4.1.1", 25 | "csstype": "^2.6.2", 26 | "dayjs": "^1.10.7", 27 | "emoji-regex": "^10.0.0", 28 | "ioredis": "^4.28.2", 29 | "jszip": "^3.7.1", 30 | "mpegts.js": "^1.6.10", 31 | "next": "^12.0.10", 32 | "next-i18next": "^10.2.0", 33 | "nextjs-progressbar": "^0.0.13", 34 | "plyr-react": "^3.2.1", 35 | "preview-office-docs": "^1.0.2", 36 | "react": "^17.0.2", 37 | "react-async-hook": "^4.0.0", 38 | "react-audio-player": "^0.17.0", 39 | "react-cookie": "^4.1.1", 40 | "react-copy-to-clipboard": "^5.0.3", 41 | "react-dom": "^17.0.2", 42 | "react-hot-toast": "^2.0.0", 43 | "react-hotkeys-hook": "^3.4.4", 44 | "react-markdown": "^8.0.0", 45 | "react-reader": "^0.20.4", 46 | "react-syntax-highlighter": "^15.4.5", 47 | "react-use-system-theme": "^1.1.1", 48 | "rehype-katex": "^6.0.2", 49 | "rehype-raw": "^6.0.0", 50 | "remark-gfm": "^3.0.1", 51 | "remark-math": "^5.1.1", 52 | "swr": "^1.2.0", 53 | "use-clipboard-copy": "^0.2.0", 54 | "use-constant": "^1.1.0" 55 | }, 56 | "devDependencies": { 57 | "@types/cors": "^2.8.12", 58 | "@types/crypto-js": "^4.0.2", 59 | "@types/ioredis": "^4.28.5", 60 | "@types/react": "17.0.38", 61 | "@types/react-copy-to-clipboard": "^5.0.0", 62 | "@types/react-dom": "^17.0.8", 63 | "@types/react-pdf": "^5.0.4", 64 | "@types/react-syntax-highlighter": "^13.5.1", 65 | "autoprefixer": "^10.4.0", 66 | "eslint": "8.8.0", 67 | "eslint-config-next": "12.0.10", 68 | "eslint-config-prettier": "^8.3.0", 69 | "i18next-parser": "^5.4.0", 70 | "postcss": "^8.4.5", 71 | "prettier": "^2.5.1", 72 | "prettier-plugin-tailwindcss": "^0.1.4", 73 | "tailwindcss": "^3.0.18", 74 | "typescript": "4.5.5" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/api.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the configuration for the API endpoints and tokens we use. 3 | * 4 | * - If you are a OneDrive International user, you would not have to change anything here. 5 | * - If you are not the admin of your OneDrive for Business account, you may need to define your own clientId/clientSecret, 6 | * check documentation for more details. 7 | * - If you are using a E5 Subscription OneDrive for Business account, the direct links of your files are not the same here. 8 | * In which case you would need to change directLinkRegex. 9 | */ 10 | module.exports = { 11 | // The clientId and clientSecret are used to authenticate the user with Microsoft Graph API using OAuth. You would 12 | // not need to change anything here if you can authenticate with your personal Microsoft account with OneDrive International. 13 | clientId: 'd87bcc39-1750-4ca0-ad54-f8d0efbb2735', 14 | obfuscatedClientSecret: 'U2FsdGVkX1830zo3/pFDqaBCVBb37iLw3WnBDWGF9GIB2f4apzv0roemp8Y+iIxI3Ih5ecyukqELQEGzZlYiWg==', 15 | 16 | // The redirectUri is the URL that the user will be redirected to after they have authenticated with Microsoft Graph API. 17 | // Likewise, you would not need to change redirectUri if you are using your personal Microsoft account with OneDrive International. 18 | redirectUri: 'http://localhost', 19 | 20 | // These are the URLs of the OneDrive API endpoints. You would not need to change anything here if you are using OneDrive International 21 | // or E5 Subscription OneDrive for Business. You may need to change these if you are using OneDrive 世纪互联. 22 | authApi: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', 23 | driveApi: 'https://graph.microsoft.com/v1.0/me/drive', 24 | 25 | // The scope we require are listed here, in most cases you would not need to change this as well. 26 | scope: 'user.read files.read.all offline_access', 27 | 28 | // The directLinkRegex is used to match the direct link of the file from the response of the API. We originally use this to prevent 29 | // unauthorised use of the proxied download feature - but that is disabled for now. So you can safely ignore this settings. 30 | directLinkRegex: 'public[.].*[.]files[.]1drv[.]com', 31 | 32 | // Cache-Control header, check Vercel documentation for more details. The default settings imply: 33 | // - max-age=0: no cache for your browser 34 | // - s-maxage=0: cache is fresh for 60 seconds on the edge, after which it becomes stale 35 | // - stale-while-revalidate: allow serving stale content while revalidating on the edge 36 | // https://vercel.com/docs/concepts/edge-network/caching 37 | cacheControlHeader: 'max-age=0, s-maxage=60, stale-while-revalidate', 38 | } 39 | -------------------------------------------------------------------------------- /components/SwitchLang.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { Menu, Transition } from '@headlessui/react' 4 | 5 | import { useRouter } from 'next/router' 6 | import Link from 'next/link' 7 | import { useCookies, withCookies } from 'react-cookie' 8 | 9 | // https://headlessui.dev/react/menu#integrating-with-next-js 10 | const CustomLink = ({ href, children, as, locale, ...props }): JSX.Element => { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | 18 | const localeText = (locale: string): string => { 19 | switch (locale) { 20 | case 'en': 21 | return '🇬🇧 English' 22 | case 'zh-CN': 23 | return '🇨🇳 简体中文' 24 | default: 25 | return '🇬🇧 English' 26 | } 27 | } 28 | 29 | const SwitchLang = () => { 30 | const { locales, pathname, query, asPath } = useRouter() 31 | 32 | const [_, setCookie] = useCookies(['NEXT_LOCALE']) 33 | 34 | return ( 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 51 | 52 | {locales!.map(locale => ( 53 | 54 | setCookie('NEXT_LOCALE', locale, { path: '/' })} 60 | > 61 |
62 | {localeText(locale)} 63 |
64 |
65 |
66 | ))} 67 |
68 |
69 |
70 |
71 | ) 72 | } 73 | 74 | export default withCookies(SwitchLang) 75 | -------------------------------------------------------------------------------- /utils/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react' 2 | 3 | type SetValue = Dispatch> 4 | 5 | function useLocalStorage(key: string, initialValue: T): [T, SetValue] { 6 | // Get from local storage then 7 | // parse stored json or return initialValue 8 | const readValue = (): T => { 9 | // Prevent build error "window is undefined" but keep keep working 10 | if (typeof window === 'undefined') { 11 | return initialValue 12 | } 13 | 14 | try { 15 | const item = window.localStorage.getItem(key) 16 | return item ? (JSON.parse(item) as T) : initialValue 17 | } catch (error) { 18 | console.warn(`Error reading localStorage key “${key}”:`, error) 19 | return initialValue 20 | } 21 | } 22 | 23 | // State to store our value 24 | // Pass initial state function to useState so logic is only executed once 25 | const [storedValue, setStoredValue] = useState(readValue) 26 | 27 | // Return a wrapped version of useState's setter function that ... 28 | // ... persists the new value to localStorage. 29 | const setValue: SetValue = value => { 30 | // Prevent build error "window is undefined" but keeps working 31 | if (typeof window == 'undefined') { 32 | console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`) 33 | } 34 | 35 | try { 36 | // Allow value to be a function so we have the same API as useState 37 | const newValue = value instanceof Function ? value(storedValue) : value 38 | 39 | // Save to local storage 40 | window.localStorage.setItem(key, JSON.stringify(newValue)) 41 | 42 | // Save state 43 | setStoredValue(newValue) 44 | 45 | // We dispatch a custom event so every useLocalStorage hook are notified 46 | window.dispatchEvent(new Event('local-storage')) 47 | } catch (error) { 48 | console.warn(`Error setting localStorage key “${key}”:`, error) 49 | } 50 | } 51 | 52 | useEffect(() => { 53 | setStoredValue(readValue()) 54 | // eslint-disable-next-line react-hooks/exhaustive-deps 55 | }, []) 56 | 57 | useEffect(() => { 58 | const handleStorageChange = () => { 59 | setStoredValue(readValue()) 60 | } 61 | 62 | // this only works for other documents, not the current one 63 | window.addEventListener('storage', handleStorageChange) 64 | 65 | // this is a custom event, triggered in writeValueToLocalStorage 66 | window.addEventListener('local-storage', handleStorageChange) 67 | 68 | return () => { 69 | window.removeEventListener('storage', handleStorageChange) 70 | window.removeEventListener('local-storage', handleStorageChange) 71 | } 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | }, []) 74 | 75 | return [storedValue, setValue] 76 | } 77 | 78 | export default useLocalStorage 79 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import '../styles/markdown-github.css' 3 | 4 | import { library } from '@fortawesome/fontawesome-svg-core' 5 | import { 6 | faFileImage, 7 | faFilePdf, 8 | faFileWord, 9 | faFilePowerpoint, 10 | faFileExcel, 11 | faFileAudio, 12 | faFileVideo, 13 | faFileArchive, 14 | faFileCode, 15 | faFileAlt, 16 | faFile, 17 | faFolder, 18 | faCopy, 19 | faArrowAltCircleDown, 20 | faTrashAlt, 21 | faEnvelope, 22 | faFlag, 23 | faCheckCircle, 24 | } from '@fortawesome/free-regular-svg-icons' 25 | import { 26 | faSearch, 27 | faPen, 28 | faCheck, 29 | faPlus, 30 | faMinus, 31 | faCopy as faCopySolid, 32 | faAngleRight, 33 | faDownload, 34 | faMusic, 35 | faArrowLeft, 36 | faArrowRight, 37 | faFileDownload, 38 | faUndo, 39 | faBook, 40 | faKey, 41 | faSignOutAlt, 42 | faCloud, 43 | faChevronCircleDown, 44 | faChevronDown, 45 | faLink, 46 | faExternalLinkAlt, 47 | faExclamationCircle, 48 | faExclamationTriangle, 49 | faTh, 50 | faThLarge, 51 | faThList, 52 | faHome, 53 | faLanguage, 54 | } from '@fortawesome/free-solid-svg-icons' 55 | import * as Icons from '@fortawesome/free-brands-svg-icons' 56 | 57 | import type { AppProps } from 'next/app' 58 | import NextNProgress from 'nextjs-progressbar' 59 | import { appWithTranslation } from 'next-i18next' 60 | 61 | // import all brand icons with tree-shaking so all icons can be referenced in the app 62 | const iconList = Object.keys(Icons) 63 | .filter(k => k !== 'fab' && k !== 'prefix') 64 | .map(icon => Icons[icon]) 65 | 66 | library.add( 67 | faFileImage, 68 | faFilePdf, 69 | faFileWord, 70 | faFilePowerpoint, 71 | faFileExcel, 72 | faFileAudio, 73 | faFileVideo, 74 | faFileArchive, 75 | faFileCode, 76 | faFileAlt, 77 | faFile, 78 | faFlag, 79 | faFolder, 80 | faMusic, 81 | faArrowLeft, 82 | faArrowRight, 83 | faAngleRight, 84 | faFileDownload, 85 | faCopy, 86 | faCopySolid, 87 | faPlus, 88 | faMinus, 89 | faDownload, 90 | faLink, 91 | faUndo, 92 | faBook, 93 | faArrowAltCircleDown, 94 | faKey, 95 | faTrashAlt, 96 | faSignOutAlt, 97 | faEnvelope, 98 | faCloud, 99 | faChevronCircleDown, 100 | faExternalLinkAlt, 101 | faExclamationCircle, 102 | faExclamationTriangle, 103 | faHome, 104 | faCheck, 105 | faCheckCircle, 106 | faSearch, 107 | faChevronDown, 108 | faTh, 109 | faThLarge, 110 | faThList, 111 | faLanguage, 112 | faPen, 113 | ...iconList 114 | ) 115 | 116 | function MyApp({ Component, pageProps }: AppProps) { 117 | return ( 118 | <> 119 | 120 | 121 | 122 | ) 123 | } 124 | export default appWithTranslation(MyApp) 125 | -------------------------------------------------------------------------------- /components/previews/EPUBPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | 3 | import { FC, useEffect, useRef, useState } from 'react' 4 | import { ReactReader } from 'react-reader' 5 | import { useRouter } from 'next/router' 6 | import { useTranslation } from 'next-i18next' 7 | 8 | import Loading from '../Loading' 9 | import DownloadButtonGroup from '../DownloadBtnGtoup' 10 | import { DownloadBtnContainer } from './Containers' 11 | import { getStoredToken } from '../../utils/protectedRouteHandler' 12 | 13 | const EPUBPreview: FC<{ file: OdFileObject }> = ({ file }) => { 14 | const { asPath } = useRouter() 15 | const hashedToken = getStoredToken(asPath) 16 | 17 | const [epubContainerWidth, setEpubContainerWidth] = useState(400) 18 | const epubContainer = useRef(null) 19 | 20 | useEffect(() => { 21 | setEpubContainerWidth(epubContainer.current ? epubContainer.current.offsetWidth : 400) 22 | }, []) 23 | 24 | const [location, setLocation] = useState() 25 | const onLocationChange = (cfiStr: string) => setLocation(cfiStr) 26 | 27 | const { t } = useTranslation() 28 | 29 | // Fix for not valid epub files according to 30 | // https://github.com/gerhardsletten/react-reader/issues/33#issuecomment-673964947 31 | const fixEpub = rendition => { 32 | const spineGet = rendition.book.spine.get.bind(rendition.book.spine) 33 | rendition.book.spine.get = function (target: string) { 34 | const targetStr = target as string 35 | let t = spineGet(target) 36 | while (t == null && targetStr.startsWith('../')) { 37 | target = targetStr.substring(3) 38 | t = spineGet(target) 39 | } 40 | return t 41 | } 42 | } 43 | 44 | return ( 45 |
46 |
50 |
51 |
58 | fixEpub(rendition)} 61 | loadingView={} 62 | location={location} 63 | locationChanged={onLocationChange} 64 | epubInitOptions={{ openAs: 'epub' }} 65 | epubOptions={{ flow: 'scrolled' }} 66 | /> 67 |
68 |
69 |
70 | 71 | 72 | 73 |
74 | ) 75 | } 76 | 77 | export default EPUBPreview 78 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // API response object for /api/?path=, this may return either a file or a folder. 2 | // Pagination is also declared here with the 'next' parameter. 3 | export type OdAPIResponse = { file?: OdFileObject; folder?: OdFolderObject; next?: string } 4 | // A folder object returned from the OneDrive API. This contains the parameter 'value', which is an array of items 5 | // inside the folder. The items may also be either files or folders. 6 | export type OdFolderObject = { 7 | '@odata.count': number 8 | '@odata.context': string 9 | '@odata.nextLink'?: string 10 | value: Array<{ 11 | id: string 12 | name: string 13 | size: number 14 | lastModifiedDateTime: string 15 | file?: { mimeType: string; hashes: { quickXorHash?: string; sha1Hash?: string; sha256Hash?: string } } 16 | folder?: { childCount: number; view: { sortBy: string; sortOrder: 'ascending'; viewType: 'thumbnails' } } 17 | image?: OdImageFile 18 | video?: OdVideoFile 19 | }> 20 | } 21 | export type OdFolderChildren = OdFolderObject['value'][number] 22 | // A file object returned from the OneDrive API. This object may contain 'video' if the file is a video. 23 | export type OdFileObject = { 24 | '@odata.context': string 25 | name: string 26 | size: number 27 | id: string 28 | lastModifiedDateTime: string 29 | file: { mimeType: string; hashes: { quickXorHash: string; sha1Hash?: string; sha256Hash?: string } } 30 | image?: OdImageFile 31 | video?: OdVideoFile 32 | } 33 | // A representation of a OneDrive image file. Some images do not return a width and height, so types are optional. 34 | export type OdImageFile = { 35 | width?: number 36 | height?: number 37 | } 38 | // A representation of a OneDrive video file. All fields are declared here, but we mainly use 'width' and 'height'. 39 | export type OdVideoFile = { 40 | width: number 41 | height: number 42 | duration: number 43 | bitrate: number 44 | frameRate: number 45 | audioBitsPerSample: number 46 | audioChannels: number 47 | audioFormat: string 48 | audioSamplesPerSecond: number 49 | } 50 | export type OdThumbnail = { 51 | id: string 52 | large: { height: number; width: number; url: string } 53 | medium: { height: number; width: number; url: string } 54 | small: { height: number; width: number; url: string } 55 | } 56 | // API response object for /api/search/?q=. Likewise, this array of items may also contain either files or folders. 57 | export type OdSearchResult = Array<{ 58 | id: string 59 | name: string 60 | file?: OdFileObject 61 | folder?: OdFolderObject 62 | path: string 63 | parentReference: { id: string; name: string; path: string } 64 | }> 65 | // API response object for /api/item/?id={id}. This is primarily used for determining the path of the driveItem by ID. 66 | export type OdDriveItem = { 67 | '@odata.context': string 68 | '@odata.etag': string 69 | id: string 70 | name: string 71 | parentReference: { driveId: string; driveType: string; id: string; path: string } 72 | } 73 | -------------------------------------------------------------------------------- /pages/api/raw.ts: -------------------------------------------------------------------------------- 1 | import { posix as pathPosix } from 'path' 2 | 3 | import type { NextApiRequest, NextApiResponse } from 'next' 4 | import axios from 'axios' 5 | import Cors from 'cors' 6 | 7 | import { driveApi } from '../../config/api.config' 8 | import { encodePath, getAccessToken, checkAuthRoute } from '.' 9 | 10 | // CORS middleware for raw links: https://nextjs.org/docs/api-routes/api-middlewares 11 | export function runCorsMiddleware(req: NextApiRequest, res: NextApiResponse) { 12 | const cors = Cors({ methods: ['GET', 'HEAD'] }) 13 | return new Promise((resolve, reject) => { 14 | cors(req, res, result => { 15 | if (result instanceof Error) { 16 | return reject(result) 17 | } 18 | 19 | return resolve(result) 20 | }) 21 | }) 22 | } 23 | 24 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 25 | const accessToken = await getAccessToken() 26 | if (!accessToken) { 27 | res.status(403).json({ error: 'No access token.' }) 28 | return 29 | } 30 | 31 | const { path = '/', odpt = '' } = req.query 32 | 33 | // Sometimes the path parameter is defaulted to '[...path]' which we need to handle 34 | if (path === '[...path]') { 35 | res.status(400).json({ error: 'No path specified.' }) 36 | return 37 | } 38 | // If the path is not a valid path, return 400 39 | if (typeof path !== 'string') { 40 | res.status(400).json({ error: 'Path query invalid.' }) 41 | return 42 | } 43 | const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path)) 44 | 45 | // Handle protected routes authentication 46 | const odTokenHeader = (req.headers['od-protected-token'] as string) ?? odpt 47 | 48 | const { code, message } = await checkAuthRoute(cleanPath, accessToken, odTokenHeader) 49 | // Status code other than 200 means user has not authenticated yet 50 | if (code !== 200) { 51 | res.status(code).json({ error: message }) 52 | return 53 | } 54 | // If message is empty, then the path is not protected. 55 | // Conversely, protected routes are not allowed to serve from cache. 56 | if (message !== '') { 57 | res.setHeader('Cache-Control', 'no-cache') 58 | } 59 | 60 | await runCorsMiddleware(req, res) 61 | try { 62 | // Handle response from OneDrive API 63 | const requestUrl = `${driveApi}/root${encodePath(cleanPath)}` 64 | const { data } = await axios.get(requestUrl, { 65 | headers: { Authorization: `Bearer ${accessToken}` }, 66 | params: { 67 | // OneDrive international version fails when only selecting the downloadUrl (what a stupid bug) 68 | select: 'id,@microsoft.graph.downloadUrl', 69 | }, 70 | }) 71 | 72 | if ('@microsoft.graph.downloadUrl' in data) { 73 | res.redirect(data['@microsoft.graph.downloadUrl']) 74 | } else { 75 | res.status(404).json({ error: 'No download url found.' }) 76 | } 77 | return 78 | } catch (error: any) { 79 | res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' }) 80 | return 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pages/api/thumbnail.ts: -------------------------------------------------------------------------------- 1 | import type { OdThumbnail } from '../../types' 2 | 3 | import { posix as pathPosix } from 'path' 4 | 5 | import axios from 'axios' 6 | import type { NextApiRequest, NextApiResponse } from 'next' 7 | 8 | import { checkAuthRoute, encodePath, getAccessToken } from '.' 9 | import apiConfig from '../../config/api.config' 10 | 11 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 12 | const accessToken = await getAccessToken() 13 | if (!accessToken) { 14 | res.status(403).json({ error: 'No access token.' }) 15 | return 16 | } 17 | 18 | // Get item thumbnails by its path since we will later check if it is protected 19 | const { path = '', size = 'medium', odpt = '' } = req.query 20 | 21 | // Set edge function caching for faster load times, if route is not protected, check docs: 22 | // https://vercel.com/docs/concepts/functions/edge-caching 23 | if (odpt === '') res.setHeader('Cache-Control', apiConfig.cacheControlHeader) 24 | 25 | // Check whether the size is valid - must be one of 'large', 'medium', or 'small' 26 | if (size !== 'large' && size !== 'medium' && size !== 'small') { 27 | res.status(400).json({ error: 'Invalid size' }) 28 | return 29 | } 30 | // Sometimes the path parameter is defaulted to '[...path]' which we need to handle 31 | if (path === '[...path]') { 32 | res.status(400).json({ error: 'No path specified.' }) 33 | return 34 | } 35 | // If the path is not a valid path, return 400 36 | if (typeof path !== 'string') { 37 | res.status(400).json({ error: 'Path query invalid.' }) 38 | return 39 | } 40 | const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path)) 41 | 42 | const { code, message } = await checkAuthRoute(cleanPath, accessToken, odpt as string) 43 | // Status code other than 200 means user has not authenticated yet 44 | if (code !== 200) { 45 | res.status(code).json({ error: message }) 46 | return 47 | } 48 | // If message is empty, then the path is not protected. 49 | // Conversely, protected routes are not allowed to serve from cache. 50 | if (message !== '') { 51 | res.setHeader('Cache-Control', 'no-cache') 52 | } 53 | 54 | const requestPath = encodePath(cleanPath) 55 | // Handle response from OneDrive API 56 | const requestUrl = `${apiConfig.driveApi}/root${requestPath}` 57 | // Whether path is root, which requires some special treatment 58 | const isRoot = requestPath === '' 59 | 60 | try { 61 | const { data } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/thumbnails`, { 62 | headers: { Authorization: `Bearer ${accessToken}` }, 63 | }) 64 | 65 | const thumbnailUrl = data.value && data.value.length > 0 ? (data.value[0] as OdThumbnail)[size].url : null 66 | if (thumbnailUrl) { 67 | res.redirect(thumbnailUrl) 68 | } else { 69 | res.status(400).json({ error: "The item doesn't have a valid thumbnail." }) 70 | } 71 | } catch (error: any) { 72 | res.status(error?.response?.status).json({ error: error?.response?.data ?? 'Internal server error.' }) 73 | } 74 | return 75 | } 76 | -------------------------------------------------------------------------------- /utils/getPreviewType.ts: -------------------------------------------------------------------------------- 1 | import { getExtension } from './getFileIcon' 2 | 3 | export const preview = { 4 | markdown: 'markdown', 5 | image: 'image', 6 | text: 'text', 7 | pdf: 'pdf', 8 | code: 'code', 9 | video: 'video', 10 | audio: 'audio', 11 | office: 'ms-office', 12 | epub: 'epub', 13 | url: 'url', 14 | } 15 | 16 | export const extensions = { 17 | gif: preview.image, 18 | jpeg: preview.image, 19 | jpg: preview.image, 20 | png: preview.image, 21 | webp: preview.image, 22 | 23 | md: preview.markdown, 24 | markdown: preview.markdown, 25 | mdown: preview.markdown, 26 | 27 | pdf: preview.pdf, 28 | 29 | doc: preview.office, 30 | docx: preview.office, 31 | ppt: preview.office, 32 | pptx: preview.office, 33 | xls: preview.office, 34 | xlsx: preview.office, 35 | 36 | c: preview.code, 37 | cpp: preview.code, 38 | js: preview.code, 39 | jsx: preview.code, 40 | java: preview.code, 41 | sh: preview.code, 42 | cs: preview.code, 43 | py: preview.code, 44 | css: preview.code, 45 | html: preview.code, 46 | // typescript or video file, determined below 47 | ts: preview.code, 48 | tsx: preview.code, 49 | rs: preview.code, 50 | vue: preview.code, 51 | json: preview.code, 52 | yml: preview.code, 53 | yaml: preview.code, 54 | toml: preview.code, 55 | 56 | txt: preview.text, 57 | vtt: preview.text, 58 | srt: preview.text, 59 | log: preview.text, 60 | diff: preview.text, 61 | 62 | mp4: preview.video, 63 | flv: preview.video, 64 | webm: preview.video, 65 | m3u8: preview.video, 66 | mkv: preview.video, 67 | mov: preview.video, 68 | avi: preview.video, // won't work! 69 | 70 | mp3: preview.audio, 71 | m4a: preview.audio, 72 | aac: preview.audio, 73 | wav: preview.audio, 74 | ogg: preview.audio, 75 | oga: preview.audio, 76 | opus: preview.audio, 77 | flac: preview.audio, 78 | 79 | epub: preview.epub, 80 | 81 | url: preview.url, 82 | } 83 | 84 | export function getPreviewType(extension: string, flags?: { video?: boolean }): string | undefined { 85 | let previewType = extensions[extension] 86 | if (!previewType) { 87 | return previewType 88 | } 89 | 90 | // Files with '.ts' extensions may be TypeScript files or TS Video files, we check for the flag 'video' 91 | // to determine what preview renderer to use for '.ts' files. 92 | if (extension === 'ts') { 93 | if (flags?.video) { 94 | previewType = preview.video 95 | } 96 | } 97 | 98 | return previewType 99 | } 100 | 101 | export function getLanguageByFileName(filename: string): string { 102 | const extension = getExtension(filename) 103 | switch (extension) { 104 | case 'ts': 105 | case 'tsx': 106 | return 'typescript' 107 | case 'rs': 108 | return 'rust' 109 | case 'js': 110 | case 'jsx': 111 | return 'javascript' 112 | case 'sh': 113 | return 'shell' 114 | case 'cs': 115 | return 'csharp' 116 | case 'py': 117 | return 'python' 118 | case 'yml': 119 | return 'yaml' 120 | default: 121 | return extension 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /components/SwitchLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react' 2 | import { IconProp } from '@fortawesome/fontawesome-svg-core' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { Listbox, Transition } from '@headlessui/react' 5 | import { useTranslation } from 'next-i18next' 6 | 7 | import useLocalStorage from '../utils/useLocalStorage' 8 | 9 | export const layouts: Array<{ id: number; name: 'Grid' | 'List'; icon: IconProp }> = [ 10 | { id: 1, name: 'List', icon: 'th-list' }, 11 | { id: 2, name: 'Grid', icon: 'th' }, 12 | ] 13 | 14 | const SwitchLayout = () => { 15 | const [preferredLayout, setPreferredLayout] = useLocalStorage('preferredLayout', layouts[0]) 16 | 17 | const { t } = useTranslation() 18 | 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 | 26 | { 27 | // t('Grid') 28 | // t('List') 29 | t(preferredLayout.name) 30 | } 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 47 | 48 | {layouts.map(layout => ( 49 | 57 | 58 | 59 | { 60 | // t('Grid') 61 | // t('List') 62 | t(layout.name) 63 | } 64 | 65 | {layout.name === preferredLayout.name && ( 66 | 67 | 68 | 69 | )} 70 | 71 | ))} 72 | 73 | 74 | 75 |
76 | ) 77 | } 78 | 79 | export default SwitchLayout 80 | -------------------------------------------------------------------------------- /utils/getFileIcon.ts: -------------------------------------------------------------------------------- 1 | import type { IconPrefix, IconName } from '@fortawesome/fontawesome-common-types' 2 | 3 | const icons: { [key: string]: [IconPrefix, IconName] } = { 4 | image: ['far', 'file-image'], 5 | pdf: ['far', 'file-pdf'], 6 | word: ['far', 'file-word'], 7 | powerpoint: ['far', 'file-powerpoint'], 8 | excel: ['far', 'file-excel'], 9 | audio: ['far', 'file-audio'], 10 | video: ['far', 'file-video'], 11 | archive: ['far', 'file-archive'], 12 | code: ['far', 'file-code'], 13 | text: ['far', 'file-alt'], 14 | file: ['far', 'file'], 15 | markdown: ['fab', 'markdown'], 16 | book: ['fas', 'book'], 17 | link: ['fas', 'link'], 18 | } 19 | 20 | const extensions = { 21 | gif: icons.image, 22 | jpeg: icons.image, 23 | jpg: icons.image, 24 | png: icons.image, 25 | heic: icons.image, 26 | webp: icons.image, 27 | 28 | pdf: icons.pdf, 29 | 30 | doc: icons.word, 31 | docx: icons.word, 32 | 33 | ppt: icons.powerpoint, 34 | pptx: icons.powerpoint, 35 | 36 | xls: icons.excel, 37 | xlsx: icons.excel, 38 | 39 | aac: icons.audio, 40 | mp3: icons.audio, 41 | ogg: icons.audio, 42 | flac: icons.audio, 43 | oga: icons.audio, 44 | opus: icons.audio, 45 | m4a: icons.audio, 46 | 47 | avi: icons.video, 48 | flv: icons.video, 49 | mkv: icons.video, 50 | mp4: icons.video, 51 | 52 | '7z': icons.archive, 53 | bz2: icons.archive, 54 | xz: icons.archive, 55 | wim: icons.archive, 56 | gz: icons.archive, 57 | rar: icons.archive, 58 | tar: icons.archive, 59 | zip: icons.archive, 60 | 61 | c: icons.code, 62 | cpp: icons.code, 63 | js: icons.code, 64 | jsx: icons.code, 65 | java: icons.code, 66 | sh: icons.code, 67 | cs: icons.code, 68 | py: icons.code, 69 | css: icons.code, 70 | html: icons.code, 71 | ts: icons.code, 72 | tsx: icons.code, 73 | rs: icons.code, 74 | vue: icons.code, 75 | json: icons.code, 76 | yml: icons.code, 77 | yaml: icons.code, 78 | toml: icons.code, 79 | 80 | txt: icons.text, 81 | rtf: icons.text, 82 | vtt: icons.text, 83 | srt: icons.text, 84 | log: icons.text, 85 | diff: icons.text, 86 | 87 | md: icons.markdown, 88 | 89 | epub: icons.book, 90 | mobi: icons.book, 91 | azw3: icons.book, 92 | 93 | url: icons.link, 94 | } 95 | 96 | /** 97 | * To stop TypeScript complaining about indexing the object with a non-existent key 98 | * https://dev.to/mapleleaf/indexing-objects-in-typescript-1cgi 99 | * 100 | * @param obj Object with keys to index 101 | * @param key The index key 102 | * @returns Whether or not the key exists inside the object 103 | */ 104 | export function hasKey(obj: O, key: PropertyKey): key is keyof O { 105 | return key in obj 106 | } 107 | 108 | export function getRawExtension(fileName: string): string { 109 | return fileName.slice(((fileName.lastIndexOf('.') - 1) >>> 0) + 2) 110 | } 111 | export function getExtension(fileName: string): string { 112 | return getRawExtension(fileName).toLowerCase() 113 | } 114 | 115 | export function getFileIcon(fileName: string, flags?: { video?: boolean }): [IconPrefix, IconName] { 116 | const extension = getExtension(fileName) 117 | let icon = hasKey(extensions, extension) ? extensions[extension] : icons.file 118 | 119 | // Files with '.ts' extensions may be TypeScript files or TS Video files, we check for the flag 'video' 120 | // to determine which icon to render for '.ts' files. 121 | if (extension === 'ts') { 122 | if (flags?.video) { 123 | icon = icons.video 124 | } 125 | } 126 | 127 | return icon 128 | } 129 | -------------------------------------------------------------------------------- /config/site.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the configuration used for customising the website, such as the folder to share, 3 | * the title, used Google fonts, site icons, contact info, etc. 4 | */ 5 | module.exports = { 6 | // This is what we use to identify who you are when you are initialising the website for the first time. 7 | // Make sure this is exactly the same as the email address you use to sign into your Microsoft account. 8 | // You can also put this in your Vercel's environment variable 'NEXT_PUBLIC_USER_PRINCIPLE_NAME' if you worry about 9 | // your email being exposed in public. 10 | userPrincipalName: process.env.NEXT_PUBLIC_USER_PRINCIPLE_NAME || 'swl8023@outlook.com', 11 | 12 | // [OPTIONAL] This is the website icon to the left of the title inside the navigation bar. It should be placed under the 13 | // /public directory of your GitHub project (not your OneDrive folder!), and referenced here by its relative path to /public. 14 | icon: '/icons/128.png', 15 | 16 | // The name of your website. Present alongside your icon. 17 | title: "Spencer's OneDrive", 18 | 19 | // The folder that you are to share publicly with onedrive-vercel-index. Use '/' if you want to share your root folder. 20 | baseDirectory: '/', 21 | 22 | // [OPTIONAL] This represents the maximum number of items that one directory lists, pagination supported. 23 | // Do note that this is limited up to 200 items by the upstream OneDrive API. 24 | maxItems: 100, 25 | 26 | // [OPTIONAL] We use Google Fonts natively for font customisations. 27 | // You can check and generate the required links and names at https://fonts.google.com. 28 | // googleFontSans - the sans serif font used in onedrive-vercel-index. 29 | googleFontSans: 'Inter', 30 | // googleFontMono - the monospace font used in onedrive-vercel-index. 31 | googleFontMono: 'Fira Mono', 32 | // googleFontLinks - an array of links for referencing the google font assets. 33 | googleFontLinks: ['https://fonts.googleapis.com/css2?family=Fira+Mono&family=Inter:wght@400;500;700&display=swap'], 34 | 35 | // [OPTIONAL] The footer component of your website. You can write HTML here, but you need to escape double 36 | // quotes - changing " to \". You can write anything here, and if you like badges, generate some with https://shields.io 37 | footer: 38 | 'Powered by onedrive-vercel-index. Made with ❤ by SpencerWoo.', 39 | 40 | // [OPTIONAL] This is where you specify the folders that are password protected. It is an array of paths pointing to all 41 | // the directories in which you have .password set. Check the documentation for details. 42 | protectedRoutes: ['/🌞 Private folder/u-need-a-password', '/🥟 Some test files/Protected route'], 43 | 44 | // [OPTIONAL] Use "" here if you want to remove this email address from the nav bar. 45 | email: 'mailto:spencer.wushangbo@gmail.com', 46 | 47 | // [OPTIONAL] This is an array of names and links for setting your social information and links. 48 | // In the latest update, all brand icons inside font awesome is supported and the icon to render is based on the name 49 | // you provide. See the documentation for details. 50 | links: [ 51 | { 52 | name: 'GitHub', 53 | link: 'https://github.com/spencerwooo/onedrive-vercel-index', 54 | }, 55 | { 56 | name: 'Telegram', 57 | link: 'https://t.me/realSpencerWoo', 58 | }, 59 | ], 60 | 61 | // This is a day.js-style datetime format string to format datetimes in the app. Ref to 62 | // https://day.js.org/docs/en/display/format for detailed specification. The default value is ISO 8601 full datetime 63 | // without timezone and replacing T with space. 64 | datetimeFormat: 'YYYY-MM-DD HH:mm:ss', 65 | } 66 | -------------------------------------------------------------------------------- /components/previews/DefaultPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | import { FC } from 'react' 3 | 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { useTranslation } from 'next-i18next' 6 | 7 | import { getFileIcon } from '../../utils/getFileIcon' 8 | import { formatModifiedDateTime, humanFileSize } from '../../utils/fileDetails' 9 | 10 | import DownloadButtonGroup from '../DownloadBtnGtoup' 11 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 12 | 13 | const DefaultPreview: FC<{ file: OdFileObject }> = ({ file }) => { 14 | const { t } = useTranslation() 15 | 16 | return ( 17 |
18 | 19 |
20 |
21 | 22 |
{file.name}
23 |
24 | 25 |
26 |
27 |
{t('Last modified')}
28 |
{formatModifiedDateTime(file.lastModifiedDateTime)}
29 |
30 | 31 |
32 |
{t('File size')}
33 |
{humanFileSize(file.size)}
34 |
35 | 36 |
37 |
{t('MIME type')}
38 |
{file.file?.mimeType ?? t('Unavailable')}
39 |
40 | 41 |
42 |
{t('Hashes')}
43 | 44 | 45 | 46 | 49 | 52 | 53 | 54 | 57 | 60 | 61 | 62 | 65 | 68 | 69 | 70 |
47 | Quick XOR 48 | 50 | {file.file.hashes?.quickXorHash ?? t('Unavailable')} 51 |
55 | SHA1 56 | 58 | {file.file.hashes?.sha1Hash ?? t('Unavailable')} 59 |
63 | SHA256 64 | 66 | {file.file.hashes?.sha256Hash ?? t('Unavailable')} 67 |
71 |
72 |
73 |
74 |
75 | 76 | 77 | 78 |
79 | ) 80 | } 81 | 82 | export default DefaultPreview 83 | -------------------------------------------------------------------------------- /components/previews/MarkdownPreview.tsx: -------------------------------------------------------------------------------- 1 | import { FC, CSSProperties, ReactNode } from 'react' 2 | import ReactMarkdown from 'react-markdown' 3 | import gfm from 'remark-gfm' 4 | import remarkMath from 'remark-math' 5 | import rehypeKatex from 'rehype-katex' 6 | import rehypeRaw from 'rehype-raw' 7 | import { useTranslation } from 'next-i18next' 8 | import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter' 9 | import { tomorrowNight } from 'react-syntax-highlighter/dist/cjs/styles/hljs' 10 | 11 | import 'katex/dist/katex.min.css' 12 | 13 | import useFileContent from '../../utils/fetchOnMount' 14 | import FourOhFour from '../FourOhFour' 15 | import Loading from '../Loading' 16 | import DownloadButtonGroup from '../DownloadBtnGtoup' 17 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 18 | 19 | const MarkdownPreview: FC<{ 20 | file: any 21 | path: string 22 | standalone?: boolean 23 | }> = ({ file, path, standalone = true }) => { 24 | // The parent folder of the markdown file, which is also the relative image folder 25 | const parentPath = standalone ? path.substring(0, path.lastIndexOf('/')) : path 26 | 27 | const { response: content, error, validating } = useFileContent(`/api/raw/?path=${parentPath}/${file.name}`, path) 28 | const { t } = useTranslation() 29 | 30 | // Check if the image is relative path instead of a absolute url 31 | const isUrlAbsolute = (url: string | string[]) => url.indexOf('://') > 0 || url.indexOf('//') === 0 32 | // Custom renderer: 33 | const customRenderer = { 34 | // img: to render images in markdown with relative file paths 35 | img: ({ 36 | alt, 37 | src, 38 | title, 39 | width, 40 | height, 41 | style, 42 | }: { 43 | alt?: string 44 | src?: string 45 | title?: string 46 | width?: string | number 47 | height?: string | number 48 | style?: CSSProperties 49 | }) => { 50 | return ( 51 | // eslint-disable-next-line @next/next/no-img-element 52 | {alt} 60 | ) 61 | }, 62 | // code: to render code blocks with react-syntax-highlighter 63 | code({ 64 | className, 65 | children, 66 | inline, 67 | ...props 68 | }: { 69 | className?: string | undefined 70 | children: ReactNode 71 | inline?: boolean 72 | }) { 73 | if (inline) { 74 | return ( 75 | 76 | {children} 77 | 78 | ) 79 | } 80 | 81 | const match = /language-(\w+)/.exec(className || '') 82 | return ( 83 | 84 | {String(children).replace(/\n$/, '')} 85 | 86 | ) 87 | }, 88 | } 89 | 90 | if (error) { 91 | return ( 92 | 93 | 94 | 95 | ) 96 | } 97 | if (validating) { 98 | return ( 99 | <> 100 | 101 | 102 | 103 | {standalone && ( 104 | 105 | 106 | 107 | )} 108 | 109 | ) 110 | } 111 | 112 | return ( 113 |
114 | 115 |
116 | {/* Using rehypeRaw to render HTML inside Markdown is potentially dangerous, use under safe environments. (#18) */} 117 | 122 | {content} 123 | 124 |
125 |
126 | {standalone && ( 127 | 128 | 129 | 130 | )} 131 |
132 | ) 133 | } 134 | 135 | export default MarkdownPreview 136 | -------------------------------------------------------------------------------- /utils/oAuthHandler.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import CryptoJS from 'crypto-js' 3 | 4 | import apiConfig from '../config/api.config' 5 | 6 | // Just a disguise to obfuscate required tokens (including but not limited to client secret, 7 | // access tokens, and refresh tokens), used along with the following two functions 8 | const AES_SECRET_KEY = 'onedrive-vercel-index' 9 | export function obfuscateToken(token: string): string { 10 | // Encrypt token with AES 11 | const encrypted = CryptoJS.AES.encrypt(token, AES_SECRET_KEY) 12 | return encrypted.toString() 13 | } 14 | export function revealObfuscatedToken(obfuscated: string): string { 15 | // Decrypt SHA256 obfuscated token 16 | const decrypted = CryptoJS.AES.decrypt(obfuscated, AES_SECRET_KEY) 17 | return decrypted.toString(CryptoJS.enc.Utf8) 18 | } 19 | 20 | // Generate the Microsoft OAuth 2.0 authorization URL, used for requesting the authorisation code 21 | export function generateAuthorisationUrl(): string { 22 | const { clientId, redirectUri, authApi, scope } = apiConfig 23 | const authUrl = authApi.replace('/token', '/authorize') 24 | 25 | // Construct URL parameters for OAuth2 26 | const params = new URLSearchParams() 27 | params.append('client_id', clientId) 28 | params.append('redirect_uri', redirectUri) 29 | params.append('response_type', 'code') 30 | params.append('scope', scope) 31 | params.append('response_mode', 'query') 32 | 33 | return `${authUrl}?${params.toString()}` 34 | } 35 | 36 | // The code returned from the Microsoft OAuth 2.0 authorization URL is a request URL with hostname 37 | // http://localhost and URL parameter code. This function extracts the code from the request URL 38 | export function extractAuthCodeFromRedirected(url: string): string { 39 | // Return empty string if the url is not the defined redirect uri 40 | if (!url.startsWith(apiConfig.redirectUri)) { 41 | return '' 42 | } 43 | 44 | // New URL search parameter 45 | const params = new URLSearchParams(url.split('?')[1]) 46 | return params.get('code') ?? '' 47 | } 48 | 49 | // After a successful authorisation, the code returned from the Microsoft OAuth 2.0 authorization URL 50 | // will be used to request an access token. This function requests the access token with the authorisation code 51 | // and returns the access token and refresh token on success. 52 | export async function requestTokenWithAuthCode( 53 | code: string 54 | ): Promise< 55 | | { expiryTime: string; accessToken: string; refreshToken: string } 56 | | { error: string; errorDescription: string; errorUri: string } 57 | > { 58 | const { clientId, redirectUri, authApi } = apiConfig 59 | const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) 60 | 61 | // Construct URL parameters for OAuth2 62 | const params = new URLSearchParams() 63 | params.append('client_id', clientId) 64 | params.append('redirect_uri', redirectUri) 65 | params.append('client_secret', clientSecret) 66 | params.append('code', code) 67 | params.append('grant_type', 'authorization_code') 68 | 69 | // Request access token 70 | return axios 71 | .post(authApi, params, { 72 | headers: { 73 | 'Content-Type': 'application/x-www-form-urlencoded', 74 | }, 75 | }) 76 | .then(resp => { 77 | const { expires_in, access_token, refresh_token } = resp.data 78 | return { expiryTime: expires_in, accessToken: access_token, refreshToken: refresh_token } 79 | }) 80 | .catch(err => { 81 | const { error, error_description, error_uri } = err.response.data 82 | return { error, errorDescription: error_description, errorUri: error_uri } 83 | }) 84 | } 85 | 86 | // Verify the identity of the user with the access token and compare it with the userPrincipalName 87 | // in the Microsoft Graph API. If the userPrincipalName matches, proceed with token storing. 88 | export async function getAuthPersonInfo(accessToken: string) { 89 | const profileApi = apiConfig.driveApi.replace('/drive', '') 90 | return axios.get(profileApi, { 91 | headers: { 92 | Authorization: `Bearer ${accessToken}`, 93 | }, 94 | }) 95 | } 96 | 97 | export async function sendTokenToServer(accessToken: string, refreshToken: string, expiryTime: string) { 98 | return await axios.post( 99 | '/api', 100 | { 101 | obfuscatedAccessToken: obfuscateToken(accessToken), 102 | accessTokenExpiry: parseInt(expiryTime), 103 | obfuscatedRefreshToken: obfuscateToken(refreshToken), 104 | }, 105 | { 106 | headers: { 107 | 'Content-Type': 'application/json', 108 | }, 109 | } 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /components/DownloadBtnGtoup.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler, useState } from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { IconProp } from '@fortawesome/fontawesome-svg-core' 4 | import toast from 'react-hot-toast' 5 | import { useClipboard } from 'use-clipboard-copy' 6 | import { useTranslation } from 'next-i18next' 7 | 8 | import Image from 'next/image' 9 | import { useRouter } from 'next/router' 10 | 11 | import { getBaseUrl } from '../utils/getBaseUrl' 12 | import { getStoredToken } from '../utils/protectedRouteHandler' 13 | import CustomEmbedLinkMenu from './CustomEmbedLinkMenu' 14 | 15 | const btnStyleMap = (btnColor?: string) => { 16 | const colorMap = { 17 | 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', 18 | 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', 19 | 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', 20 | 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', 21 | green: 22 | 'hover:text-green-600 focus:ring-green-200 focus:text-green-600 border-green-300 dark:border-green-700 dark:focus:ring-green-500', 23 | 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', 24 | yellow: 25 | 'hover:text-yellow-400 focus:ring-yellow-100 focus:text-yellow-400 border-yellow-300 dark:border-yellow-400 dark:focus:ring-yellow-300', 26 | } 27 | 28 | if (btnColor) { 29 | return colorMap[btnColor] 30 | } 31 | 32 | return colorMap.gray 33 | } 34 | 35 | export const DownloadButton = ({ 36 | onClickCallback, 37 | btnColor, 38 | btnText, 39 | btnIcon, 40 | btnImage, 41 | btnTitle, 42 | }: { 43 | onClickCallback: MouseEventHandler 44 | btnColor?: string 45 | btnText: string 46 | btnIcon?: IconProp 47 | btnImage?: string 48 | btnTitle?: string 49 | }) => { 50 | return ( 51 | 62 | ) 63 | } 64 | 65 | const DownloadButtonGroup = () => { 66 | const { asPath } = useRouter() 67 | const hashedToken = getStoredToken(asPath) 68 | 69 | const clipboard = useClipboard() 70 | const [menuOpen, setMenuOpen] = useState(false) 71 | 72 | const { t } = useTranslation() 73 | 74 | return ( 75 | <> 76 | 77 |
78 | window.open(`/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`)} 80 | btnColor="blue" 81 | btnText={t('Download')} 82 | btnIcon="file-download" 83 | btnTitle={t('Download the file directly through OneDrive')} 84 | /> 85 | {/* window.open(`/api/proxy?url=${encodeURIComponent(downloadUrl)}`)} 87 | btnColor="teal" 88 | btnText={t('Proxy download')} 89 | btnIcon="download" 90 | btnTitle={t('Download the file with the stream proxied through Vercel Serverless')} 91 | /> */} 92 | { 94 | clipboard.copy(`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`) 95 | toast.success(t('Copied direct link to clipboard.')) 96 | }} 97 | btnColor="pink" 98 | btnText={t('Copy direct link')} 99 | btnIcon="copy" 100 | btnTitle={t('Copy the permalink to the file to the clipboard')} 101 | /> 102 | setMenuOpen(true)} 104 | btnColor="teal" 105 | btnText={t('Customise link')} 106 | btnIcon="pen" 107 | /> 108 |
109 | 110 | ) 111 | } 112 | 113 | export default DownloadButtonGroup 114 | -------------------------------------------------------------------------------- /components/previews/AudioPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | import { FC, useEffect, useRef, useState } from 'react' 3 | 4 | import ReactAudioPlayer from 'react-audio-player' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { useTranslation } from 'next-i18next' 7 | import { useRouter } from 'next/router' 8 | 9 | import DownloadButtonGroup from '../DownloadBtnGtoup' 10 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 11 | import { LoadingIcon } from '../Loading' 12 | import { formatModifiedDateTime } from '../../utils/fileDetails' 13 | import { getStoredToken } from '../../utils/protectedRouteHandler' 14 | 15 | enum PlayerState { 16 | Loading, 17 | Ready, 18 | Playing, 19 | Paused, 20 | } 21 | 22 | const AudioPreview: FC<{ file: OdFileObject }> = ({ file }) => { 23 | const { t } = useTranslation() 24 | const { asPath } = useRouter() 25 | const hashedToken = getStoredToken(asPath) 26 | 27 | const rapRef = useRef(null) 28 | const [playerStatus, setPlayerStatus] = useState(PlayerState.Loading) 29 | 30 | // Render audio thumbnail, and also check for broken thumbnails 31 | const thumbnail = `/api/thumbnail/?path=${asPath}&size=medium${hashedToken ? `&odpt=${hashedToken}` : ''}` 32 | const [brokenThumbnail, setBrokenThumbnail] = useState(false) 33 | 34 | useEffect(() => { 35 | // Manually get the HTML audio element and set onplaying event. 36 | // - As the default event callbacks provided by the React component does not guarantee playing state to be set 37 | // - properly when the user seeks through the timeline or the audio is buffered. 38 | const rap = (rapRef.current as ReactAudioPlayer).audioEl.current 39 | if (rap) { 40 | rap.oncanplay = () => setPlayerStatus(PlayerState.Ready) 41 | rap.onended = () => setPlayerStatus(PlayerState.Paused) 42 | rap.onpause = () => setPlayerStatus(PlayerState.Paused) 43 | rap.onplay = () => setPlayerStatus(PlayerState.Playing) 44 | rap.onplaying = () => setPlayerStatus(PlayerState.Playing) 45 | rap.onseeking = () => setPlayerStatus(PlayerState.Loading) 46 | rap.onwaiting = () => setPlayerStatus(PlayerState.Loading) 47 | rap.onerror = () => setPlayerStatus(PlayerState.Paused) 48 | } 49 | }, []) 50 | 51 | return ( 52 | <> 53 | 54 |
55 |
56 |
63 | 64 |
65 | 66 | {!brokenThumbnail ? ( 67 |
68 | {/* eslint-disable-next-line @next/next/no-img-element */} 69 | {file.name} setBrokenThumbnail(true)} 76 | /> 77 |
78 | ) : ( 79 | 84 | )} 85 |
86 | 87 |
88 |
89 |
{file.name}
90 |
91 | {t('Last modified:') + ' ' + formatModifiedDateTime(file.lastModifiedDateTime)} 92 |
93 |
94 | 95 | 102 |
103 |
104 |
105 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | 113 | export default AudioPreview 114 | -------------------------------------------------------------------------------- /components/CustomEmbedLinkMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, Fragment, SetStateAction, useRef, useState } from 'react' 2 | import { useTranslation } from 'next-i18next' 3 | import { Dialog, Transition } from '@headlessui/react' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { useClipboard } from 'use-clipboard-copy' 6 | 7 | import { getBaseUrl } from '../utils/getBaseUrl' 8 | import { getStoredToken } from '../utils/protectedRouteHandler' 9 | import { getReadablePath } from '../utils/getReadablePath' 10 | 11 | function LinkContainer({ title, value }: { title: string; value: string }) { 12 | const clipboard = useClipboard({ copiedTimeout: 1000 }) 13 | return ( 14 | <> 15 |

{title}

16 |
17 |
{value}
18 | 24 |
25 | 26 | ) 27 | } 28 | 29 | export default function CustomEmbedLinkMenu({ 30 | path, 31 | menuOpen, 32 | setMenuOpen, 33 | }: { 34 | path: string 35 | menuOpen: boolean 36 | setMenuOpen: Dispatch> 37 | }) { 38 | const { t } = useTranslation() 39 | 40 | const hashedToken = getStoredToken(path) 41 | 42 | // Focus on input automatically when menu modal opens 43 | const focusInputRef = useRef(null) 44 | const closeMenu = () => setMenuOpen(false) 45 | 46 | const readablePath = getReadablePath(path) 47 | const filename = readablePath.substring(readablePath.lastIndexOf('/') + 1) 48 | const [name, setName] = useState(filename) 49 | 50 | return ( 51 | 52 | 53 |
54 | 63 | 64 | 65 | 66 | {/* This element is to trick the browser into centering the modal contents. */} 67 | 70 | 79 |
80 | 81 | {t('Customise direct link')} 82 | 83 | 84 | {t('Change the raw file direct link to a URL ending with the extension of the file.')}{' '} 85 | 91 | {t('What is this?')} 92 | 93 | 94 | 95 |
96 |

{t('Filename')}

97 | setName(e.target.value)} 102 | /> 103 | 104 | 108 | 112 | 118 | 122 |
123 |
124 |
125 |
126 |
127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /components/previews/VideoPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFileObject } from '../../types' 2 | 3 | import { FC, useEffect, useState } from 'react' 4 | import { useRouter } from 'next/router' 5 | import { useTranslation } from 'next-i18next' 6 | 7 | import axios from 'axios' 8 | import toast from 'react-hot-toast' 9 | import Plyr from 'plyr-react' 10 | import { useAsync } from 'react-async-hook' 11 | import { useClipboard } from 'use-clipboard-copy' 12 | 13 | import { getBaseUrl } from '../../utils/getBaseUrl' 14 | import { getExtension } from '../../utils/getFileIcon' 15 | import { getStoredToken } from '../../utils/protectedRouteHandler' 16 | 17 | import { DownloadButton } from '../DownloadBtnGtoup' 18 | import { DownloadBtnContainer, PreviewContainer } from './Containers' 19 | import FourOhFour from '../FourOhFour' 20 | import Loading from '../Loading' 21 | import CustomEmbedLinkMenu from '../CustomEmbedLinkMenu' 22 | 23 | import 'plyr-react/dist/plyr.css' 24 | 25 | const VideoPlayer: FC<{ 26 | videoName: string 27 | videoUrl: string 28 | width?: number 29 | height?: number 30 | thumbnail: string 31 | subtitle: string 32 | isFlv: boolean 33 | mpegts: any 34 | }> = ({ videoName, videoUrl, width, height, thumbnail, subtitle, isFlv, mpegts }) => { 35 | useEffect(() => { 36 | // Really really hacky way to inject subtitles as file blobs into the video element 37 | axios 38 | .get(subtitle, { responseType: 'blob' }) 39 | .then(resp => { 40 | const track = document.querySelector('track') 41 | track?.setAttribute('src', URL.createObjectURL(resp.data)) 42 | }) 43 | .catch(() => { 44 | console.log('Could not load subtitle.') 45 | }) 46 | 47 | if (isFlv) { 48 | const loadFlv = () => { 49 | // Really hacky way to get the exposed video element from Plyr 50 | const video = document.getElementById('plyr') 51 | const flv = mpegts.createPlayer({ url: videoUrl, type: 'flv' }) 52 | flv.attachMediaElement(video) 53 | flv.load() 54 | } 55 | loadFlv() 56 | } 57 | }, [videoUrl, isFlv, mpegts, subtitle]) 58 | 59 | // Common plyr configs, including the video source and plyr options 60 | const plyrSource = { 61 | type: 'video', 62 | title: videoName, 63 | poster: thumbnail, 64 | tracks: [{ kind: 'captions', label: videoName, src: '', default: true }], 65 | } 66 | const plyrOptions: Plyr.Options = { 67 | ratio: `${width ?? 16}:${height ?? 9}`, 68 | } 69 | if (!isFlv) { 70 | // If the video is not in flv format, we can use the native plyr and add sources directly with the video URL 71 | plyrSource['sources'] = [{ src: videoUrl }] 72 | } 73 | return 74 | } 75 | 76 | const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => { 77 | const { asPath } = useRouter() 78 | const hashedToken = getStoredToken(asPath) 79 | const clipboard = useClipboard() 80 | 81 | const [menuOpen, setMenuOpen] = useState(false) 82 | const { t } = useTranslation() 83 | 84 | // OneDrive generates thumbnails for its video files, we pick the thumbnail with the highest resolution 85 | const thumbnail = `/api/thumbnail/?path=${asPath}&size=large${hashedToken ? `&odpt=${hashedToken}` : ''}` 86 | 87 | // We assume subtitle files are beside the video with the same name, only webvtt '.vtt' files are supported 88 | const vtt = `${asPath.substring(0, asPath.lastIndexOf('.'))}.vtt` 89 | const subtitle = `/api/raw/?path=${vtt}${hashedToken ? `&odpt=${hashedToken}` : ''}` 90 | 91 | // We also format the raw video file for the in-browser player as well as all other players 92 | const videoUrl = `/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}` 93 | 94 | const isFlv = getExtension(file.name) === 'flv' 95 | const { 96 | loading, 97 | error, 98 | result: mpegts, 99 | } = useAsync(async () => { 100 | if (isFlv) { 101 | return (await import('mpegts.js')).default 102 | } 103 | }, [isFlv]) 104 | 105 | return ( 106 | <> 107 | 108 | 109 | {error ? ( 110 | 111 | ) : loading && isFlv ? ( 112 | 113 | ) : ( 114 | 124 | )} 125 | 126 | 127 | 128 |
129 | window.open(videoUrl)} 131 | btnColor="blue" 132 | btnText={t('Download')} 133 | btnIcon="file-download" 134 | /> 135 | {/* 137 | window.open(`/api/proxy?url=${encodeURIComponent(...)}`) 138 | } 139 | btnColor="teal" 140 | btnText={t('Proxy download')} 141 | btnIcon="download" 142 | /> */} 143 | { 145 | clipboard.copy(`${getBaseUrl()}/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`) 146 | toast.success(t('Copied direct link to clipboard.')) 147 | }} 148 | btnColor="pink" 149 | btnText={t('Copy direct link')} 150 | btnIcon="copy" 151 | /> 152 | setMenuOpen(true)} 154 | btnColor="teal" 155 | btnText={t('Customise link')} 156 | btnIcon="pen" 157 | /> 158 | 159 | window.open(`iina://weblink?url=${getBaseUrl()}${videoUrl}`)} 161 | btnText="IINA" 162 | btnImage="/players/iina.png" 163 | /> 164 | window.open(`vlc://${getBaseUrl()}${videoUrl}`)} 166 | btnText="VLC" 167 | btnImage="/players/vlc.png" 168 | /> 169 | window.open(`potplayer://${getBaseUrl()}/${videoUrl}`)} 171 | btnText="PotPlayer" 172 | btnImage="/players/potplayer.png" 173 | /> 174 |
175 |
176 | 177 | ) 178 | } 179 | 180 | export default VideoPreview 181 | -------------------------------------------------------------------------------- /pages/onedrive-vercel-index-oauth/step-2.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { useRouter } from 'next/router' 4 | import { useState } from 'react' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { useTranslation, Trans } from 'next-i18next' 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations' 8 | 9 | import siteConfig from '../../config/site.config' 10 | import Navbar from '../../components/Navbar' 11 | import Footer from '../../components/Footer' 12 | import { LoadingIcon } from '../../components/Loading' 13 | import { extractAuthCodeFromRedirected, generateAuthorisationUrl } from '../../utils/oAuthHandler' 14 | 15 | export default function OAuthStep2() { 16 | const router = useRouter() 17 | 18 | const [oAuthRedirectedUrl, setOAuthRedirectedUrl] = useState('') 19 | const [authCode, setAuthCode] = useState('') 20 | const [buttonLoading, setButtonLoading] = useState(false) 21 | 22 | const { t } = useTranslation() 23 | 24 | const oAuthUrl = generateAuthorisationUrl() 25 | 26 | return ( 27 |
28 | 29 | {t('OAuth Step 2 - {{title}}', { title: siteConfig.title })} 30 | 31 | 32 |
33 | 34 | 35 |
36 |
37 |
38 | fabulous come back later 45 |
46 |

47 | {t('Welcome to your new onedrive-vercel-index 🎉')} 48 |

49 | 50 |

{t('Step 2/3: Get authorisation code')}

51 | 52 |

53 | 54 | If you are not the owner of this website, 55 | stop now, as continuing with this process may expose your personal files in OneDrive. 56 | 57 |

58 | 59 |
{ 62 | window.open(oAuthUrl) 63 | }} 64 | > 65 |
66 | 67 |
68 |
 69 |                 {oAuthUrl}
 70 |               
71 |
72 | 73 |

74 | 75 | The OAuth link for getting the authorisation code has been created. Click on the link above to get the{' '} 76 | authorisation code. Your browser will 77 | {/* eslint-disable-next-line react/no-unescaped-entities */} 78 | open a new tab to Microsoft's account login page. After logging in and authenticating with your 79 | Microsoft account, you will be redirected to a blank page on localhost. Paste{' '} 80 | the entire redirected URL down below. 81 | 82 |

83 | 84 |
85 | step 2 screenshot 86 |
87 | 88 | { 99 | setOAuthRedirectedUrl(e.target.value) 100 | setAuthCode(extractAuthCodeFromRedirected(e.target.value)) 101 | }} 102 | /> 103 | 104 |

{t('The authorisation code extracted is:')}

105 |

106 | {authCode ?? {t('Waiting for code...')}} 107 |

108 | 109 |

110 | {authCode 111 | ? t('✅ You can now proceed onto the next step: requesting your access token and refresh token.') 112 | : t('❌ No valid code extracted.')} 113 |

114 | 115 |
116 | 134 |
135 |
136 |
137 |
138 | 139 |
140 |
141 | ) 142 | } 143 | 144 | export async function getServerSideProps({ locale }) { 145 | return { 146 | props: { 147 | ...(await serverSideTranslations(locale, ['common'])), 148 | }, 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /public/locales/zh-CN/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "- showing {{count}} page(s) ——other": "已显示 {{count}} 页", 3 | "{{count}} item(s)——other": "{{count}} 个项目", 4 | "<0> If you are not the owner of this website, stop now, as continuing with this process may expose your personal files in OneDrive.": "<0> 如果你不是这个网站的所有者,请立即停止操作,因为接下来的操作可能会暴露你的 OneDrive 私人文件。", 5 | "<0> If you have not specified a REDIS_URL inside your Vercel env variable, go initialise one at <3>Upstash. Docs: <6>Vercel Integration - Upstash.": "<0> 如果你还没有在 Vercel 中设置环境变量 REDIS_URL,你可以从 <3>Upstash 处获取一个来使用。文档:<6>Vercel 集成 - Upstash。", 6 | "<0> If you see anything missing or incorrect, you need to reconfigure <3>/config/api.config.js and redeploy this instance.": "<0> 如果你看到有遗漏或错误的项目,你需要重新编辑 <3>/config/api.config.js 并重新部署这个实例。", 7 | "✅ You can now proceed onto the next step: requesting your access token and refresh token.": "✅ 你现在可以进行下一步了:获取你的 access token 和 refresh token。", 8 | "❌ No valid code extracted.": "❌ 无法提取授权码。", 9 | "Acquired access_token: ": "获取 access_token", 10 | "Acquired refresh_token: ": "获取 refresh_token", 11 | "Actions": "操作", 12 | "Authorisation is required as no valid <2>access_token or <5>refresh_token is present on this deployed instance. Check the following configurations before proceeding with authorising onedrive-vercel-index with your own Microsoft account.": "本项目还没有设置有效的 <2>access_token 和 <5>refresh_token,需要进行授权。在继续对 onedrive-vercel-index 授权你的 Microsoft 帐号前,请检查一下下方的配置信息。", 13 | "Cancel": "取消", 14 | "Cannot preview {{path}}": "无法预览 {{path}}", 15 | "Change the raw file direct link to a URL ending with the extension of the file.": "将文件直链接更改为以文件扩展名结尾的 URL。", 16 | "Check out <2>Microsoft's official explanation on the error message.": "请查阅 <2>Microsoft 官方解释 以获取详细的错误信息。", 17 | "Clear all": "清除所有密钥", 18 | "Clear all tokens?": "清除所有密钥?", 19 | "Cleared all tokens": "已清除所有密钥", 20 | "clearing them means that you will need to re-enter the passwords again.": "清除它们意味着下次访问时你需要重新输入密钥。", 21 | "Copied direct link to clipboard.": "已复制直链到剪贴板。", 22 | "Copied folder permalink.": "已复制文件夹永久链接。", 23 | "Copied raw file permalink.": "已复制文件永久链接。", 24 | "Copy direct link": "复制文件直链", 25 | "Copy folder permalink": "复制文件夹永久链接", 26 | "Copy raw file permalink": "复制文件永久链接", 27 | "Copy the permalink to the file to the clipboard": "复制文件永久链接到剪贴板", 28 | "Customise direct link": "自定义文件直链", 29 | "Customise link": "自定义直链", 30 | "Customised": "自定义链接", 31 | "Customised and encoded": "URL 编码的自定义链接", 32 | "Default": "默认", 33 | "Do not pretend to be the site owner": "你不是网站所有者", 34 | "Don't worry, after storing them, onedrive-vercel-index will take care of token refreshes and updates after your site goes live.": "别担心,存储它们之后,onedrive-vercel-index 会在帮助你定时更新 token", 35 | "Download": "下载", 36 | "Download file": "下载此文件", 37 | "Download folder": "下载此文件夹", 38 | "Download selected files": "下载选定文件", 39 | "Download the file directly through OneDrive": "直接从 OneDrive 下载文件", 40 | "Downloading {{progress}}%": "已下载 {{progress}}%", 41 | "Downloading folder, refresh page to cancel": "下载文件夹中,刷新页面以取消", 42 | "Downloading selected files, refresh page to cancel": "下载选定文件中,刷新页面以取消", 43 | "Downloading selected files...": "下载选定文件中…", 44 | "Email": "电子邮件", 45 | "Enter Password": "输入密码", 46 | "Error storing the token": "存储 token 时出错", 47 | "Error validating identify, restart": "校验身份出错,需要重新开始", 48 | "Error: {{message}}": "错误:{{message}}", 49 | "Failed to download folder {{path}}: {{status}} {{message}} Skipped it to continue.": "下载文件夹 {{path}} 失败:{{status}} {{message}} 已忽略此错误并继续下载。", 50 | "Failed to download folder.": "下载文件夹失败。", 51 | "Failed to download selected files.": "下载选定文件失败。", 52 | "File is empty.": "文件为空。", 53 | "File size": "文件大小", 54 | "Filename": "文件名", 55 | "Final step, click the button below to store these tokens persistently before they expire after {{minutes}} minutes {{seconds}} seconds. ": "最后一步,在这些 tokens 于 {{minutes}} 分钟 {{seconds}} 秒后失效前,点击下方按钮以永久存储这些 tokens。", 56 | "Finished downloading folder.": "下载文件夹成功。", 57 | "Finished downloading selected files.": "下载选定文件成功。", 58 | "Get tokens": "获取 tokens", 59 | "Grid": "图格", 60 | "Hashes": "哈希值", 61 | "Home": "首页", 62 | "If you go back home and still see the welcome page telling you to re-authenticate, ": "如果你回到首页却仍然发现欢迎界面在提示你重新认证,", 63 | "If you know the password, please enter it below.": "如果你知晓密码,请在下面输入。", 64 | "Last modified": "最后修改时间", 65 | "Last Modified": "最后修改时间", 66 | "Last modified:": "最后修改时间:", 67 | "List": "列表", 68 | "Load more": "加载更多", 69 | "Loading ...": "加载中…", 70 | "Loading EPUB ...": "加载 EPUB 中…", 71 | "Loading file content...": "加载文件内容中…", 72 | "Loading FLV extension...": "加载 FLV 扩展中…", 73 | "Logout": "注销", 74 | "MIME type": "MIME 类型", 75 | "Name": "文件名", 76 | "No more files": "加载完毕", 77 | "Nothing here.": "无内容。", 78 | "OAuth Step 1 - {{title}}": "OAuth 第 1 步 - {{title}}", 79 | "OAuth Step 2 - {{title}}": "OAuth 第 2 步 - {{title}}", 80 | "OAuth Step 3 - {{title}}": "OAuth 第 3 步 - {{title}}", 81 | "of {{count}} file(s) -——loaded——other": "共 {{count}} 个文件", 82 | "of {{count}} file(s) -——loading——other": "共…个文件", 83 | "Oops, that's a <1>four-oh-four.": "Oops,这里是 <1>404 页面。", 84 | "Open URL": "打开 URL", 85 | "Open URL{{url}}": "打开 URL{{url}}", 86 | "Press <2>F12 and open devtools for more details, or seek help at <6>onedrive-vercel-index discussions.": "请按下 <2>F12 来打开开发者工具窗口以获取详细信息,或是到 <6>onedrive-vercel-index 社区讨论 处寻求帮助。", 87 | "Proceed to OAuth": "继续进行 OAuth", 88 | "Requesting tokens": "正在获取 token", 89 | "Restart": "重新开始", 90 | "revisit home and do a hard refresh.": "重新访问首页并刷新浏览器", 91 | "Search ...": "搜索…", 92 | "Select all files": "选择所有文件", 93 | "Select file": "选择此文件", 94 | "Select files": "选择以下文件", 95 | "Size": "文件大小", 96 | "Step 1/3: Preparations": "步骤 1/3:准备", 97 | "Step 2/3: Get authorisation code": "步骤 2/3:获取授权码", 98 | "Step 3/3: Get access and refresh tokens": "步骤 3/3:获取 access token 和 refresh token", 99 | "Store tokens": "储存 tokens", 100 | "Stored! Going home...": "已存储!正在返回首页…", 101 | "Storing tokens": "正在存储 token…", 102 | "Success! The API returned what we needed.": "成功!需要的 token 已被返回。", 103 | "The authorisation code extracted is:": "提取出的授权码为:", 104 | "The OAuth link for getting the authorisation code has been created. Click on the link above to get the <2>authorisation code. Your browser willopen 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 <6>the entire redirected URL down below.": "创建出的这个 OAuth 链接是用来获取授权码的。点击上方链接以获取所需的 <2>授权码。你的浏览器将在新的标签页打开 Microsoft 帐号登录页面。在登录并验证你的 Microsoft 帐号之后,你将被重定向到一个域名为 localhost 的空白页面。请将<6>完整的重定向后的 URL 整体复制粘贴到下方。", 105 | "These tokens are used to authenticate yourself into password protected folders, ": "这些密钥是用来验证你的身份以访问密钥保护下的文件夹的,", 106 | "These tokens may take a few seconds to populate after you click the button below. ": "当你点击下方按钮之后,这些 tokens 可能需要几秒钟来生成出现。", 107 | "This route (the folder itself and the files inside) is password protected. ": "此路由(此文件夹和其中的文件)是受密钥保护的。", 108 | "Unavailable": "无", 109 | "URL encoded": "URL 编码的链接", 110 | "Waiting for code...": "等待授权码…", 111 | "Weibo": "微博", 112 | "Welcome to your new onedrive-vercel-index 🎉": "欢迎来到你崭新的 onedrive-vercel-index 🎉", 113 | "What is this?": "这是什么?", 114 | "Where is the auth code? Did you follow step 2 you silly donut?": "授权码呢?你遵守了第 2 步吗?你这个傻瓜甜甜圈!o( ̄ヘ ̄o#)", 115 | "Whoops, looks like we got a problem: {{error}}.": "Whoops,看来我们遇到了一个问题:{{error}}" 116 | } 117 | -------------------------------------------------------------------------------- /components/FolderListLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFolderChildren } from '../types' 2 | 3 | import Link from 'next/link' 4 | import { FC } from 'react' 5 | import { useClipboard } from 'use-clipboard-copy' 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 7 | import { useTranslation } from 'next-i18next' 8 | 9 | import { getBaseUrl } from '../utils/getBaseUrl' 10 | import { humanFileSize, formatModifiedDateTime } from '../utils/fileDetails' 11 | 12 | import { Downloading, Checkbox, ChildIcon, ChildName } from './FileListing' 13 | import { getStoredToken } from '../utils/protectedRouteHandler' 14 | 15 | const FileListItem: FC<{ fileContent: OdFolderChildren }> = ({ fileContent: c }) => { 16 | return ( 17 |
18 |
19 |
20 | 21 |
22 | 23 |
24 |
25 | {formatModifiedDateTime(c.lastModifiedDateTime)} 26 |
27 |
28 | {humanFileSize(c.size)} 29 |
30 |
31 | ) 32 | } 33 | 34 | const FolderListLayout = ({ 35 | path, 36 | folderChildren, 37 | selected, 38 | toggleItemSelected, 39 | totalSelected, 40 | toggleTotalSelected, 41 | totalGenerating, 42 | handleSelectedDownload, 43 | folderGenerating, 44 | handleFolderDownload, 45 | toast, 46 | }) => { 47 | const clipboard = useClipboard() 48 | const hashedToken = getStoredToken(path) 49 | 50 | const { t } = useTranslation() 51 | 52 | // Get item path from item name 53 | const getItemPath = (name: string) => `${path === '/' ? '' : path}/${encodeURIComponent(name)}` 54 | 55 | return ( 56 |
57 |
58 |
59 | {t('Name')} 60 |
61 |
62 | {t('Last Modified')} 63 |
64 |
65 | {t('Size')} 66 |
67 |
68 | {t('Actions')} 69 |
70 |
71 |
72 | 78 | {totalGenerating ? ( 79 | 80 | ) : ( 81 | 89 | )} 90 |
91 |
92 |
93 | 94 | {folderChildren.map((c: OdFolderChildren) => ( 95 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | {c.folder ? ( 106 |
107 | { 111 | clipboard.copy(`${getBaseUrl()}${`${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`}`) 112 | toast(t('Copied folder permalink.'), { icon: '👌' }) 113 | }} 114 | > 115 | 116 | 117 | {folderGenerating[c.id] ? ( 118 | 119 | ) : ( 120 | { 124 | const p = `${path === '/' ? '' : path}/${encodeURIComponent(c.name)}` 125 | handleFolderDownload(p, c.id, c.name)() 126 | }} 127 | > 128 | 129 | 130 | )} 131 |
132 | ) : ( 133 |
134 | { 138 | clipboard.copy( 139 | `${getBaseUrl()}/api/raw/?path=${getItemPath(c.name)}${hashedToken ? `&odpt=${hashedToken}` : ''}` 140 | ) 141 | toast.success(t('Copied raw file permalink.')) 142 | }} 143 | > 144 | 145 | 146 | 151 | 152 | 153 |
154 | )} 155 |
156 | {!c.folder && !(c.name === '.password') && ( 157 | toggleItemSelected(c.id)} 160 | title={t('Select file')} 161 | /> 162 | )} 163 |
164 |
165 | ))} 166 |
167 | ) 168 | } 169 | 170 | export default FolderListLayout 171 | -------------------------------------------------------------------------------- /pages/onedrive-vercel-index-oauth/step-1.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { useRouter } from 'next/router' 4 | import { useTranslation, Trans } from 'next-i18next' 5 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations' 6 | 7 | import siteConfig from '../../config/site.config' 8 | import apiConfig from '../../config/api.config' 9 | import Navbar from '../../components/Navbar' 10 | import Footer from '../../components/Footer' 11 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 12 | 13 | export default function OAuthStep1() { 14 | const router = useRouter() 15 | 16 | const { t } = useTranslation() 17 | 18 | return ( 19 |
20 | 21 | {t('OAuth Step 1 - {{title}}', { title: siteConfig.title })} 22 | 23 | 24 |
25 | 26 | 27 |
28 |
29 |
30 | fabulous fireworks 31 |
32 |

33 | {t('Welcome to your new onedrive-vercel-index 🎉')} 34 |

35 | 36 |

{t('Step 1/3: Preparations')}

37 | 38 |

39 | 40 | If you have not specified a REDIS_URL 41 | inside your Vercel env variable, go initialise one at{' '} 42 | 43 | Upstash 44 | 45 | . Docs:{' '} 46 | 52 | Vercel Integration - Upstash 53 | 54 | . 55 | 56 |

57 | 58 |

59 | 60 | Authorisation is required as no valid{' '} 61 | access_token or{' '} 62 | refresh_token{' '} 63 | is present on this deployed instance. Check the following configurations before proceeding with 64 | authorising onedrive-vercel-index with your own Microsoft account. 65 | 66 |

67 | 68 |
69 | 70 | 71 | 72 | 75 | 78 | 79 | 80 | 83 | 86 | 87 | 88 | 91 | 94 | 95 | 96 | 99 | 102 | 103 | 104 | 107 | 110 | 111 | 112 | 115 | 118 | 119 | 120 |
73 | CLIENT_ID 74 | 76 | {apiConfig.clientId} 77 |
81 | CLIENT_SECRET* 82 | 84 | {apiConfig.obfuscatedClientSecret} 85 |
89 | REDIRECT_URI 90 | 92 | {apiConfig.redirectUri} 93 |
97 | Auth API URL 98 | 100 | {apiConfig.authApi} 101 |
105 | Drive API URL 106 | 108 | {apiConfig.driveApi} 109 |
113 | API Scope 114 | 116 | {apiConfig.scope} 117 |
121 |
122 | 123 |

124 | 125 | If you see anything 126 | missing or incorrect, you need to reconfigure{' '} 127 | /config/api.config.js and redeploy this instance. 128 | 129 |

130 | 131 |
132 | 140 |
141 |
142 |
143 |
144 | 145 |
146 |
147 | ) 148 | } 149 | 150 | export async function getServerSideProps({ locale }) { 151 | return { 152 | props: { 153 | ...(await serverSideTranslations(locale, ['common'])), 154 | }, 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | onedrive-vercel-index 3 |

onedrive-vercel-index

4 |

Get started · What's new? · Sponsoring

5 |

OneDrive public directory listing, powered by Vercel and Next.js

6 | 7 | OneDrive 8 | Next.js 9 | Vercel 10 | Documentation 11 | GitHub Discussions 12 |
13 | 14 | ## TL;DR 15 | 16 | Showcase, share, preview, and download files inside *your* OneDrive with onedrive-vercel-index - 17 | 18 | - Completely free to host 💸 19 | - Super fast ⚡ and responsive 💦 20 | - Takes less than 15 minutes to setup ⏱️ 21 | - Highly customisable ⚒️ 22 | 23 | 🍌 More importantly, we are pretty (●'◡'●) 24 | 25 | ## Quick start 26 | 27 | 🚀 Quick start: [Getting started](https://ovi.swo.moe/docs/getting-started). 28 | 29 | ## Discussion 30 | 31 | Please go to our [discussion forum](https://github.com/spencerwooo/onedrive-vercel-index/discussions) for general questions and FAQs, **issues are for bug reports and bug reports only.** Feature requests may or may not be ignored, as [I (@spencerwooo)](https://spencerwoo.com) am the only one maintaining the project, so **I only prioritise features that I use.** 32 | 33 | *If you happen to like this project, please give it a star!* :3 34 | 35 | *If you really, really like this project, please send money! -> [Sponsors 🤑 and donations 💰](https://ovi.swo.moe/sponsor)* 36 | 37 | ## Demo 38 | 39 | Live demo at [Spencer's OneDrive](https://drive.swo.moe). 40 | 41 | ![demo](./public/demo.png) 42 | 43 | ## Features 44 | 45 | 46 | 47 | 48 | 54 | 60 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 98 | 102 | 103 | 104 |
49 | 👀 File preview 53 | 55 | 💠 List / Grid layouts 59 | 61 | 🎥 Video and audio 65 |
PDF, EPUB, markdown, code, plain textFor previewing images and documents with thumbnailsmp4, mp3, ..., play online or with IINA, PotPlayer ... with subtitles!
74 | 📄 Office preview 78 | 📝 README.md preview📑 Pagination
docx, pptx, xlsx, ...Also renders code blocks, images with relative links, ...For folders with 200 or more items
🔒 Protected folders⏬ Multi-file download🔎 Native Search
Password protected routes and files. Details here 95 | Compress and download multiple files or folders. 96 | Details here 97 | 99 | Searching through your shared OneDrive files (with some caveats 🥺). 100 | Details here 101 |
105 | 106 | ... and more: 107 | 108 | - Streamlined deployment, without having to get your tokens manually anymore! 109 | - Direct raw-file serving and hosting ... 110 | - Full dark mode support, style and website customisations ... 111 | 112 | ## Documentation 113 | 114 | Documentation is hosted at [onedrive-vercel-index.spencerwoo.com](https://ovi.swo.moe/). 115 | 116 | - How can I get started and deploy? - [Docs - Getting started](https://ovi.swo.moe/docs/getting-started). 117 | - How can I configure ... ? - [Docs - Custom configs](https://ovi.swo.moe/docs/custom-configs). 118 | - Where is feature ... ? 119 | - [Docs - Password protected folders](https://ovi.swo.moe/docs/features/protected-folders) 120 | - [Docs - Multi-file and folder download](https://ovi.swo.moe/docs/features/multi-file-folder-download) 121 | - [Docs - Hosting files (images) directly](https://ovi.swo.moe/docs/features/hosting-images-directly) 122 | - [Docs - Search for files and folders](https://ovi.swo.moe/docs/features/search-for-files-and-folders) 123 | - [Docs - Load video subtitles](https://ovi.swo.moe/docs/features/load-video-subtitles) 124 | - I deployed this before, how can I upgrade to the latest version? - [Docs - Updating to the latest version](https://ovi.swo.moe/docs/migration/updating-to-latest-version) 125 | - I was here before 2022, how can I migrate to the new version? - [Docs - Migrating from versions before 2022](https://ovi.swo.moe/docs/migration/if-you-deployed-before-2022). 126 | - I got a problem during deployment ... - [Docs - FAQ](https://ovi.swo.moe/docs/faqs/error-on-deployment) 127 | - I didn't find a solution / My problem is unique - [Find help in discussion forum](https://github.com/spencerwooo/onedrive-vercel-index/discussions). 128 | 129 | ## Server-*less* (free)? 130 | 131 | Yes! Completely free with no backend server what-so-ever. (Well, we use Redis, but that's free to some extent also.) 132 | 133 | ## Sponsors and donations! 134 | 135 | Open-source is hard! If you happen to like this project and want me to keep going, please consider sponsoring me or providing a single donation! Thanks for all the love and support! 136 | 137 | [🧸 Please donate - 微信/支付宝](https://ovi.swo.moe/sponsor) · [Patreon](https://www.patreon.com/spencerwoo) · [爱发电](https://afdian.net/@spencerwoo) 138 | 139 | ### Sponsors 140 | 141 | *Your name will appear here if you sponsor or donate 😀* 142 | 143 | --- 144 | 145 | **onedrive-vercel-index** ©Spencer Woo. Released under the MIT License. 146 | 147 | Authored and maintained by Spencer Woo. 148 | 149 | > [@Portfolio](https://spencerwoo.com/) · [@Blog](https://blog.spencerwoo.com/) · [@GitHub](https://github.com/spencerwooo) 150 | -------------------------------------------------------------------------------- /components/FolderGridLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { OdFolderChildren } from '../types' 2 | 3 | import Link from 'next/link' 4 | import { useState } from 'react' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { useClipboard } from 'use-clipboard-copy' 7 | import { useTranslation } from 'next-i18next' 8 | 9 | import { getBaseUrl } from '../utils/getBaseUrl' 10 | import { formatModifiedDateTime } from '../utils/fileDetails' 11 | import { Checkbox, ChildIcon, ChildName, Downloading } from './FileListing' 12 | import { getStoredToken } from '../utils/protectedRouteHandler' 13 | 14 | const GridItem = ({ c, path }: { c: OdFolderChildren; path: string }) => { 15 | // We use the generated medium thumbnail for rendering preview images (excluding folders) 16 | const hashedToken = getStoredToken(path) 17 | const thumbnailUrl = 18 | 'folder' in c ? null : `/api/thumbnail/?path=${path}&size=medium${hashedToken ? `&odpt=${hashedToken}` : ''}` 19 | 20 | // Some thumbnails are broken, so we check for onerror event in the image component 21 | const [brokenThumbnail, setBrokenThumbnail] = useState(false) 22 | 23 | return ( 24 |
25 |
26 | {thumbnailUrl && !brokenThumbnail ? ( 27 | // eslint-disable-next-line @next/next/no-img-element 28 | {c.name} setBrokenThumbnail(true)} 33 | /> 34 | ) : ( 35 |
36 | 37 | 38 | {c.folder?.childCount} 39 | 40 |
41 | )} 42 |
43 | 44 |
45 | 46 | 47 | 48 | 49 |
50 |
51 | {formatModifiedDateTime(c.lastModifiedDateTime)} 52 |
53 |
54 | ) 55 | } 56 | 57 | const FolderGridLayout = ({ 58 | path, 59 | folderChildren, 60 | selected, 61 | toggleItemSelected, 62 | totalSelected, 63 | toggleTotalSelected, 64 | totalGenerating, 65 | handleSelectedDownload, 66 | folderGenerating, 67 | handleFolderDownload, 68 | toast, 69 | }) => { 70 | const clipboard = useClipboard() 71 | const hashedToken = getStoredToken(path) 72 | 73 | const { t } = useTranslation() 74 | 75 | // Get item path from item name 76 | const getItemPath = (name: string) => `${path === '/' ? '' : path}/${encodeURIComponent(name)}` 77 | 78 | return ( 79 |
80 |
81 |
{t('{{count}} item(s)', { count: folderChildren.length })}
82 |
83 | 89 | {totalGenerating ? ( 90 | 91 | ) : ( 92 | 100 | )} 101 |
102 |
103 | 104 |
105 | {folderChildren.map((c: OdFolderChildren) => ( 106 |
110 |
111 | {c.folder ? ( 112 |
113 | { 117 | clipboard.copy(`${getBaseUrl()}${getItemPath(c.name)}`) 118 | toast(t('Copied folder permalink.'), { icon: '👌' }) 119 | }} 120 | > 121 | 122 | 123 | {folderGenerating[c.id] ? ( 124 | 125 | ) : ( 126 | 131 | 132 | 133 | )} 134 |
135 | ) : ( 136 |
137 | { 141 | clipboard.copy( 142 | `${getBaseUrl()}/api/raw/?path=${getItemPath(c.name)}${ 143 | hashedToken ? `&odpt=${hashedToken}` : '' 144 | }` 145 | ) 146 | toast.success(t('Copied raw file permalink.')) 147 | }} 148 | > 149 | 150 | 151 | 158 | 159 | 160 |
161 | )} 162 |
163 | 164 |
169 | {!c.folder && !(c.name === '.password') && ( 170 | toggleItemSelected(c.id)} 173 | title={t('Select file')} 174 | /> 175 | )} 176 |
177 | 178 | 179 | 180 | 181 | 182 | 183 |
184 | ))} 185 |
186 |
187 | ) 188 | } 189 | 190 | export default FolderGridLayout 191 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 2 | import { IconName } from '@fortawesome/fontawesome-svg-core' 3 | import { Dialog, Transition } from '@headlessui/react' 4 | import toast, { Toaster } from 'react-hot-toast' 5 | import { useHotkeys } from 'react-hotkeys-hook' 6 | 7 | import Link from 'next/link' 8 | import Image from 'next/image' 9 | import { useRouter } from 'next/router' 10 | import { Fragment, useEffect, useState } from 'react' 11 | import { useTranslation } from 'next-i18next' 12 | 13 | import siteConfig from '../config/site.config' 14 | import SearchModal from './SearchModal' 15 | import SwitchLang from './SwitchLang' 16 | import useDeviceOS from '../utils/useDeviceOS' 17 | 18 | const Navbar = () => { 19 | const router = useRouter() 20 | const os = useDeviceOS() 21 | 22 | const [tokenPresent, setTokenPresent] = useState(false) 23 | const [isOpen, setIsOpen] = useState(false) 24 | 25 | const [searchOpen, setSearchOpen] = useState(false) 26 | const openSearchBox = () => setSearchOpen(true) 27 | 28 | useHotkeys(`${os === 'mac' ? 'cmd' : 'ctrl'}+k`, e => { 29 | openSearchBox() 30 | e.preventDefault() 31 | }) 32 | 33 | useEffect(() => { 34 | const storedToken = () => { 35 | for (const r of siteConfig.protectedRoutes) { 36 | if (localStorage.hasOwnProperty(r)) { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | setTokenPresent(storedToken()) 43 | }, []) 44 | 45 | const { t } = useTranslation() 46 | 47 | const clearTokens = () => { 48 | setIsOpen(false) 49 | 50 | siteConfig.protectedRoutes.forEach(r => { 51 | localStorage.removeItem(r) 52 | }) 53 | 54 | toast.success(t('Cleared all tokens')) 55 | setTimeout(() => { 56 | router.reload() 57 | }, 1000) 58 | } 59 | 60 | return ( 61 |
62 | 63 | 64 | 65 | 66 |
67 | 68 | 69 | icon 70 | {siteConfig.title} 71 | 72 | 73 | 74 |
75 | 91 | 92 | 93 | 94 | {siteConfig.links.length !== 0 && 95 | siteConfig.links.map((l: { name: string; link: string }) => ( 96 | 103 | 104 | 105 | { 106 | // Append link name comments here to add translations 107 | // t('Weibo') 108 | t(l.name) 109 | } 110 | 111 | 112 | ))} 113 | 114 | {siteConfig.email && ( 115 | 116 | 117 | {t('Email')} 118 | 119 | )} 120 | 121 | {tokenPresent && ( 122 | 129 | )} 130 |
131 |
132 | 133 | 134 | setIsOpen(false)}> 135 |
136 | 145 | 146 | 147 | 148 | {/* This element is to trick the browser into centering the modal contents. */} 149 | 152 | 161 |
162 | 163 | {t('Clear all tokens?')} 164 | 165 |
166 |

167 | {t('These tokens are used to authenticate yourself into password protected folders, ') + 168 | t('clearing them means that you will need to re-enter the passwords again.')} 169 |

170 |
171 | 172 |
173 | {siteConfig.protectedRoutes.map((r, i) => ( 174 |
175 | 176 | {r} 177 |
178 | ))} 179 |
180 | 181 |
182 | 188 | 195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 | ) 203 | } 204 | 205 | export default Navbar 206 | -------------------------------------------------------------------------------- /components/MultiFileDownloader.tsx: -------------------------------------------------------------------------------- 1 | import { NextRouter } from 'next/router' 2 | import toast from 'react-hot-toast' 3 | import JSZip from 'jszip' 4 | import { useTranslation } from 'next-i18next' 5 | 6 | import { fetcher } from '../utils/fetchWithSWR' 7 | import { getStoredToken } from '../utils/protectedRouteHandler' 8 | 9 | /** 10 | * A loading toast component with file download progress support 11 | * @param props 12 | * @param props.router Next router instance, used for reloading the page 13 | * @param props.progress Current downloading and compression progress (returned by jszip metadata) 14 | */ 15 | export function DownloadingToast({ router, progress }: { router: NextRouter; progress?: string }) { 16 | const { t } = useTranslation() 17 | 18 | return ( 19 |
20 |
21 | {progress ? t('Downloading {{progress}}%', { progress }) : t('Downloading selected files...')} 22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 | 35 |
36 | ) 37 | } 38 | 39 | // Blob download helper 40 | export function downloadBlob({ blob, name }: { blob: Blob; name: string }) { 41 | // Prepare for download 42 | const el = document.createElement('a') 43 | el.style.display = 'none' 44 | document.body.appendChild(el) 45 | 46 | // Download zip file 47 | const bUrl = window.URL.createObjectURL(blob) 48 | el.href = bUrl 49 | el.download = name 50 | el.click() 51 | window.URL.revokeObjectURL(bUrl) 52 | el.remove() 53 | } 54 | 55 | /** 56 | * Download multiple files after compressing them into a zip 57 | * @param toastId Toast ID to be used for toast notification 58 | * @param files Files to be downloaded 59 | * @param folder Optional folder name to hold files, otherwise flatten files in the zip 60 | */ 61 | export async function downloadMultipleFiles({ 62 | toastId, 63 | router, 64 | files, 65 | folder, 66 | }: { 67 | toastId: string 68 | router: NextRouter 69 | files: { name: string; url: string }[] 70 | folder?: string 71 | }): Promise { 72 | const zip = new JSZip() 73 | const dir = folder ? zip.folder(folder)! : zip 74 | 75 | // Add selected file blobs to zip 76 | files.forEach(({ name, url }) => { 77 | dir.file( 78 | name, 79 | fetch(url).then(r => { 80 | return r.blob() 81 | }) 82 | ) 83 | }) 84 | 85 | // Create zip file and download it 86 | const b = await zip.generateAsync({ type: 'blob' }, metadata => { 87 | toast.loading(, { 88 | id: toastId, 89 | }) 90 | }) 91 | downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' }) 92 | } 93 | 94 | /** 95 | * Download hierarchical tree-like files after compressing them into a zip 96 | * @param toastId Toast ID to be used for toast notification 97 | * @param files Files to be downloaded. Array of file and folder items excluding root folder. 98 | * Folder items MUST be in front of its children items in the array. 99 | * Use async generator because generation of the array may be slow. 100 | * When waiting for its generation, we can meanwhile download bodies of already got items. 101 | * Only folder items can have url undefined. 102 | * @param basePath Root dir path of files to be downloaded 103 | * @param folder Optional folder name to hold files, otherwise flatten files in the zip 104 | */ 105 | export async function downloadTreelikeMultipleFiles({ 106 | toastId, 107 | router, 108 | files, 109 | basePath, 110 | folder, 111 | }: { 112 | toastId: string 113 | router: NextRouter 114 | files: AsyncGenerator<{ 115 | name: string 116 | url?: string 117 | path: string 118 | isFolder: boolean 119 | }> 120 | basePath: string 121 | folder?: string 122 | }): Promise { 123 | const zip = new JSZip() 124 | const root = folder ? zip.folder(folder)! : zip 125 | const map = [{ path: basePath, dir: root }] 126 | 127 | // Add selected file blobs to zip according to its path 128 | for await (const { name, url, path, isFolder } of files) { 129 | // Search parent dir in map 130 | const i = map 131 | .slice() 132 | .reverse() 133 | .findIndex( 134 | ({ path: parent }) => 135 | path.substring(0, parent.length) === parent && path.substring(parent.length + 1).indexOf('/') === -1 136 | ) 137 | if (i === -1) { 138 | throw new Error('File array does not satisfy requirement') 139 | } 140 | 141 | // Add file or folder to zip 142 | const dir = map[map.length - 1 - i].dir 143 | if (isFolder) { 144 | map.push({ path, dir: dir.folder(name)! }) 145 | } else { 146 | dir.file( 147 | name, 148 | fetch(url!).then(r => r.blob()) 149 | ) 150 | } 151 | } 152 | 153 | // Create zip file and download it 154 | const b = await zip.generateAsync({ type: 'blob' }, metadata => { 155 | toast.loading(, { 156 | id: toastId, 157 | }) 158 | }) 159 | downloadBlob({ blob: b, name: folder ? folder + '.zip' : 'download.zip' }) 160 | } 161 | 162 | interface TraverseItem { 163 | path: string 164 | meta: any 165 | isFolder: boolean 166 | error?: { status: number; message: string } 167 | } 168 | 169 | /** 170 | * One-shot concurrent top-down file traversing for the folder. 171 | * Due to react hook limit, we cannot reuse SWR utils for recursive actions. 172 | * We will directly fetch API and arrange responses instead. 173 | * In folder tree, we visit folders top-down as concurrently as possible. 174 | * Every time we visit a folder, we fetch and return meta of all its children. 175 | * If folders have pagination, partically retrieved items are not returned immediately, 176 | * but after all children of the folder have been successfully retrieved. 177 | * If an error occurred in paginated fetching, all children will be dropped. 178 | * @param path Folder to be traversed. The path should be cleaned in advance. 179 | * @returns Array of items representing folders and files of traversed folder top-down and excluding root folder. 180 | * Due to top-down, Folder items are ALWAYS in front of its children items. 181 | * Error key in the item will contain the error when there is a handleable error. 182 | */ 183 | export async function* traverseFolder(path: string): AsyncGenerator { 184 | const hashedToken = getStoredToken(path) 185 | 186 | // Generate the task passed to Promise.race to request a folder 187 | const genTask = async (i: number, path: string, next?: string) => { 188 | return { 189 | i, 190 | path, 191 | data: await fetcher( 192 | next ? `/api/?path=${path}&next=${next}` : `/api?path=${path}`, 193 | hashedToken ?? undefined 194 | ).catch(error => ({ i, path, error })), 195 | } 196 | } 197 | 198 | // Pool containing Promises of folder requests 199 | let pool = [genTask(0, path)] 200 | 201 | // Map as item buffer for folders with pagination 202 | const buf: { [k: string]: TraverseItem[] } = {} 203 | 204 | // filter(() => true) removes gaps in the array 205 | while (pool.filter(() => true).length > 0) { 206 | let info: { i: number; path: string; data: any } 207 | try { 208 | info = await Promise.race(pool.filter(() => true)) 209 | } catch (error: any) { 210 | const { i, path, error: innerError } = error 211 | // 4xx errors are identified as handleable errors 212 | if (Math.floor(innerError.status / 100) === 4) { 213 | delete pool[i] 214 | yield { 215 | path, 216 | meta: {}, 217 | isFolder: true, 218 | error: { status: innerError.status, message: innerError.message.error }, 219 | } 220 | continue 221 | } else { 222 | throw error 223 | } 224 | } 225 | 226 | const { i, path, data } = info 227 | if (!data || !data.folder) { 228 | throw new Error('Path is not folder') 229 | } 230 | delete pool[i] 231 | 232 | const items = data.folder.value.map((c: any) => { 233 | const p = `${path === '/' ? '' : path}/${encodeURIComponent(c.name)}` 234 | return { path: p, meta: c, isFolder: Boolean(c.folder) } 235 | }) as TraverseItem[] 236 | 237 | if (data.next) { 238 | buf[path] = (buf[path] ?? []).concat(items) 239 | 240 | // Append next page task to the pool at the end 241 | const i = pool.length 242 | pool[i] = genTask(i, path, data.next) 243 | } else { 244 | const allItems = (buf[path] ?? []).concat(items) 245 | if (buf[path]) { 246 | delete buf[path] 247 | } 248 | 249 | allItems 250 | .filter(item => item.isFolder) 251 | .forEach(item => { 252 | // Append new folder tasks to the pool at the end 253 | const i = pool.length 254 | pool[i] = genTask(i, item.path) 255 | }) 256 | yield* allItems 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /components/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import useSWR, { SWRResponse } from 'swr' 3 | import { Dispatch, Fragment, SetStateAction, useState } from 'react' 4 | import AwesomeDebouncePromise from 'awesome-debounce-promise' 5 | import { useAsync } from 'react-async-hook' 6 | import useConstant from 'use-constant' 7 | import { useTranslation } from 'next-i18next' 8 | 9 | import Link from 'next/link' 10 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 11 | import { Dialog, Transition } from '@headlessui/react' 12 | 13 | import type { OdDriveItem, OdSearchResult } from '../types' 14 | import { LoadingIcon } from './Loading' 15 | 16 | import { getFileIcon } from '../utils/getFileIcon' 17 | import { fetcher } from '../utils/fetchWithSWR' 18 | import siteConfig from '../config/site.config' 19 | 20 | /** 21 | * Extract the searched item's path in field 'parentReference' and convert it to the 22 | * absolute path represented in onedrive-vercel-index 23 | * 24 | * @param path Path returned from the parentReference field of the driveItem 25 | * @returns The absolute path of the driveItem in the search result 26 | */ 27 | function mapAbsolutePath(path: string): string { 28 | // path is in the format of '/drive/root:/path/to/file', if baseDirectory is '/' then we split on 'root:', 29 | // otherwise we split on the user defined 'baseDirectory' 30 | const absolutePath = path.split(siteConfig.baseDirectory === '/' ? 'root:' : siteConfig.baseDirectory)[1] 31 | // path returned by the API may contain #, by doing a decodeURIComponent and then encodeURIComponent we can 32 | // replace URL sensitive characters such as the # with %23 33 | return absolutePath 34 | .split('/') 35 | .map(p => encodeURIComponent(decodeURIComponent(p))) 36 | .join('/') 37 | } 38 | 39 | /** 40 | * Implements a debounced search function that returns a promise that resolves to an array of 41 | * search results. 42 | * 43 | * @returns A react hook for a debounced async search of the drive 44 | */ 45 | function useDriveItemSearch() { 46 | const [query, setQuery] = useState('') 47 | const searchDriveItem = async (q: string) => { 48 | const { data } = await axios.get(`/api/search/?q=${q}`) 49 | 50 | // Map parentReference to the absolute path of the search result 51 | data.map(item => { 52 | item['path'] = 53 | 'path' in item.parentReference 54 | ? // OneDrive International have the path returned in the parentReference field 55 | `${mapAbsolutePath(item.parentReference.path)}/${encodeURIComponent(item.name)}` 56 | : // OneDrive for Business/Education does not, so we need extra steps here 57 | '' 58 | }) 59 | 60 | return data 61 | } 62 | 63 | const debouncedDriveItemSearch = useConstant(() => AwesomeDebouncePromise(searchDriveItem, 1000)) 64 | const results = useAsync(async () => { 65 | if (query.length === 0) { 66 | return [] 67 | } else { 68 | return debouncedDriveItemSearch(query) 69 | } 70 | }, [query]) 71 | 72 | return { 73 | query, 74 | setQuery, 75 | results, 76 | } 77 | } 78 | 79 | function SearchResultItemTemplate({ 80 | driveItem, 81 | driveItemPath, 82 | itemDescription, 83 | disabled, 84 | }: { 85 | driveItem: OdSearchResult[number] 86 | driveItemPath: string 87 | itemDescription: string 88 | disabled: boolean 89 | }) { 90 | return ( 91 | 92 | 97 | 98 |
99 |
{driveItem.name}
100 |
105 | {itemDescription} 106 |
107 |
108 |
109 | 110 | ) 111 | } 112 | 113 | function SearchResultItemLoadRemote({ result }: { result: OdSearchResult[number] }) { 114 | const { data, error }: SWRResponse = useSWR(`/api/item/?id=${result.id}`, fetcher) 115 | 116 | const { t } = useTranslation() 117 | 118 | if (error) { 119 | return 120 | } 121 | if (!data) { 122 | return ( 123 | 129 | ) 130 | } 131 | 132 | const driveItemPath = `${mapAbsolutePath(data.parentReference.path)}/${encodeURIComponent(data.name)}` 133 | return ( 134 | 140 | ) 141 | } 142 | 143 | function SearchResultItem({ result }: { result: OdSearchResult[number] }) { 144 | if (result.path === '') { 145 | // path is empty, which means we need to fetch the parentReference to get the path 146 | return 147 | } else { 148 | // path is not an empty string in the search result, such that we can directly render the component as is 149 | const driveItemPath = decodeURIComponent(result.path) 150 | return ( 151 | 157 | ) 158 | } 159 | } 160 | 161 | export default function SearchModal({ 162 | searchOpen, 163 | setSearchOpen, 164 | }: { 165 | searchOpen: boolean 166 | setSearchOpen: Dispatch> 167 | }) { 168 | const { query, setQuery, results } = useDriveItemSearch() 169 | 170 | const { t } = useTranslation() 171 | 172 | const closeSearchBox = () => { 173 | setSearchOpen(false) 174 | setQuery('') 175 | } 176 | 177 | return ( 178 | 179 | 180 |
181 | 190 | 191 | 192 | 193 | 202 |
203 | 207 | 208 | setQuery(e.target.value)} 215 | /> 216 |
ESC
217 |
218 |
222 | {results.loading && ( 223 |
224 | 225 | {t('Loading ...')} 226 |
227 | )} 228 | {results.error && ( 229 |
230 | {t('Error: {{message}}', { message: results.error.message })} 231 |
232 | )} 233 | {results.result && ( 234 | <> 235 | {results.result.length === 0 ? ( 236 |
{t('Nothing here.')}
237 | ) : ( 238 | results.result.map(result => ) 239 | )} 240 | 241 | )} 242 |
243 |
244 |
245 |
246 |
247 |
248 | ) 249 | } 250 | -------------------------------------------------------------------------------- /public/locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "- showing {{count}} page(s) ——one": "- showing {{count}} page ", 3 | "- showing {{count}} page(s) ——other": "- showing {{count}} pages ", 4 | "{{count}} item(s)——one": "{{count}} item", 5 | "{{count}} item(s)——other": "{{count}} items", 6 | "<0> If you are not the owner of this website, stop now, as continuing with this process may expose your personal files in OneDrive.": "<0> If you are not the owner of this website, stop now, as continuing with this process may expose your personal files in OneDrive.", 7 | "<0> If you have not specified a REDIS_URL inside your Vercel env variable, go initialise one at <3>Upstash. Docs: <6>Vercel Integration - Upstash.": "<0> If you have not specified a REDIS_URL inside your Vercel env variable, go initialise one at <3>Upstash. Docs: <6>Vercel Integration - Upstash.", 8 | "<0> If you see anything missing or incorrect, you need to reconfigure <3>/config/api.config.js and redeploy this instance.": "<0> If you see anything missing or incorrect, you need to reconfigure <3>/config/api.config.js and redeploy this instance.", 9 | "✅ You can now proceed onto the next step: requesting your access token and refresh token.": "✅ You can now proceed onto the next step: requesting your access token and refresh token.", 10 | "❌ No valid code extracted.": "❌ No valid code extracted.", 11 | "Acquired access_token: ": "Acquired access_token: ", 12 | "Acquired refresh_token: ": "Acquired refresh_token: ", 13 | "Actions": "Actions", 14 | "Authorisation is required as no valid <2>access_token or <5>refresh_token is present on this deployed instance. Check the following configurations before proceeding with authorising onedrive-vercel-index with your own Microsoft account.": "Authorisation is required as no valid <2>access_token or <5>refresh_token is present on this deployed instance. Check the following configurations before proceeding with authorising onedrive-vercel-index with your own Microsoft account.", 15 | "Cancel": "Cancel", 16 | "Cannot preview {{path}}": "Cannot preview {{path}}", 17 | "Change the raw file direct link to a URL ending with the extension of the file.": "Change the raw file direct link to a URL ending with the extension of the file.", 18 | "Check out <2>Microsoft's official explanation on the error message.": "Check out <2>Microsoft's official explanation on the error message.", 19 | "Clear all": "Clear all", 20 | "Clear all tokens?": "Clear all tokens?", 21 | "Cleared all tokens": "Cleared all tokens", 22 | "clearing them means that you will need to re-enter the passwords again.": "clearing them means that you will need to re-enter the passwords again.", 23 | "Copied direct link to clipboard.": "Copied direct link to clipboard.", 24 | "Copied folder permalink.": "Copied folder permalink.", 25 | "Copied raw file permalink.": "Copied raw file permalink.", 26 | "Copy direct link": "Copy direct link", 27 | "Copy folder permalink": "Copy folder permalink", 28 | "Copy raw file permalink": "Copy raw file permalink", 29 | "Copy the permalink to the file to the clipboard": "Copy the permalink to the file to the clipboard", 30 | "Customise direct link": "Customise direct link", 31 | "Customise link": "Customise link", 32 | "Customised": "Customised", 33 | "Customised and encoded": "Customised and encoded", 34 | "Default": "Default", 35 | "Do not pretend to be the site owner": "Do not pretend to be the site owner", 36 | "Don't worry, after storing them, onedrive-vercel-index will take care of token refreshes and updates after your site goes live.": "Don't worry, after storing them, onedrive-vercel-index will take care of token refreshes and updates after your site goes live.", 37 | "Download": "Download", 38 | "Download file": "Download file", 39 | "Download folder": "Download folder", 40 | "Download selected files": "Download selected files", 41 | "Download the file directly through OneDrive": "Download the file directly through OneDrive", 42 | "Downloading {{progress}}%": "Downloading {{progress}}%", 43 | "Downloading folder, refresh page to cancel": "Downloading folder, refresh page to cancel", 44 | "Downloading selected files, refresh page to cancel": "Downloading selected files, refresh page to cancel", 45 | "Downloading selected files...": "Downloading selected files...", 46 | "Email": "Email", 47 | "Enter Password": "Enter Password", 48 | "Error storing the token": "Error storing the token", 49 | "Error validating identify, restart": "Error validating identify, restart", 50 | "Error: {{message}}": "Error: {{message}}", 51 | "Failed to download folder {{path}}: {{status}} {{message}} Skipped it to continue.": "Failed to download folder {{path}}: {{status}} {{message}} Skipped it to continue.", 52 | "Failed to download folder.": "Failed to download folder.", 53 | "Failed to download selected files.": "Failed to download selected files.", 54 | "File is empty.": "File is empty.", 55 | "File size": "File size", 56 | "Filename": "Filename", 57 | "Final step, click the button below to store these tokens persistently before they expire after {{minutes}} minutes {{seconds}} seconds. ": "Final step, click the button below to store these tokens persistently before they expire after {{minutes}} minutes {{seconds}} seconds. ", 58 | "Finished downloading folder.": "Finished downloading folder.", 59 | "Finished downloading selected files.": "Finished downloading selected files.", 60 | "Get tokens": "Get tokens", 61 | "Grid": "Grid", 62 | "Hashes": "Hashes", 63 | "Home": "Home", 64 | "If you go back home and still see the welcome page telling you to re-authenticate, ": "If you go back home and still see the welcome page telling you to re-authenticate, ", 65 | "If you know the password, please enter it below.": "If you know the password, please enter it below.", 66 | "Last modified": "Last modified", 67 | "Last Modified": "Last Modified", 68 | "Last modified:": "Last modified:", 69 | "List": "List", 70 | "Load more": "Load more", 71 | "Loading ...": "Loading ...", 72 | "Loading EPUB ...": "Loading EPUB ...", 73 | "Loading file content...": "Loading file content...", 74 | "Loading FLV extension...": "Loading FLV extension...", 75 | "Logout": "Logout", 76 | "MIME type": "MIME type", 77 | "Name": "Name", 78 | "No more files": "No more files", 79 | "Nothing here.": "Nothing here.", 80 | "OAuth Step 1 - {{title}}": "OAuth Step 1 - {{title}}", 81 | "OAuth Step 2 - {{title}}": "OAuth Step 2 - {{title}}", 82 | "OAuth Step 3 - {{title}}": "OAuth Step 3 - {{title}}", 83 | "of {{count}} file(s) -——loaded——one": "of {{count}} file -", 84 | "of {{count}} file(s) -——loaded——other": "of {{count}} files -", 85 | "of {{count}} file(s) -——loading——one": "of ... file(s) -", 86 | "of {{count}} file(s) -——loading——other": "of ... file(s) -", 87 | "Oops, that's a <1>four-oh-four.": "Oops, that's a <1>four-oh-four.", 88 | "Open URL": "Open URL", 89 | "Open URL{{url}}": "Open URL{{url}}", 90 | "Press <2>F12 and open devtools for more details, or seek help at <6>onedrive-vercel-index discussions.": "Press <2>F12 and open devtools for more details, or seek help at <6>onedrive-vercel-index discussions.", 91 | "Proceed to OAuth": "Proceed to OAuth", 92 | "Requesting tokens": "Requesting tokens", 93 | "Restart": "Restart", 94 | "revisit home and do a hard refresh.": "revisit home and do a hard refresh.", 95 | "Search ...": "Search ...", 96 | "Select all files": "Select all files", 97 | "Select file": "Select file", 98 | "Select files": "Select files", 99 | "Size": "Size", 100 | "Step 1/3: Preparations": "Step 1/3: Preparations", 101 | "Step 2/3: Get authorisation code": "Step 2/3: Get authorisation code", 102 | "Step 3/3: Get access and refresh tokens": "Step 3/3: Get access and refresh tokens", 103 | "Store tokens": "Store tokens", 104 | "Stored! Going home...": "Stored! Going home...", 105 | "Storing tokens": "Storing tokens", 106 | "Success! The API returned what we needed.": "Success! The API returned what we needed.", 107 | "The authorisation code extracted is:": "The authorisation code extracted is:", 108 | "The OAuth link for getting the authorisation code has been created. Click on the link above to get the <2>authorisation code. Your browser willopen 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 <6>the entire redirected URL down below.": "The OAuth link for getting the authorisation code has been created. Click on the link above to get the <2>authorisation code. Your browser willopen 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 <6>the entire redirected URL down below.", 109 | "These tokens are used to authenticate yourself into password protected folders, ": "These tokens are used to authenticate yourself into password protected folders, ", 110 | "These tokens may take a few seconds to populate after you click the button below. ": "These tokens may take a few seconds to populate after you click the button below. ", 111 | "This route (the folder itself and the files inside) is password protected. ": "This route (the folder itself and the files inside) is password protected. ", 112 | "Unavailable": "Unavailable", 113 | "URL encoded": "URL encoded", 114 | "Waiting for code...": "Waiting for code...", 115 | "Weibo": "Weibo", 116 | "Welcome to your new onedrive-vercel-index 🎉": "Welcome to your new onedrive-vercel-index 🎉", 117 | "What is this?": "What is this?", 118 | "Where is the auth code? Did you follow step 2 you silly donut?": "Where is the auth code? Did you follow step 2 you silly donut?", 119 | "Whoops, looks like we got a problem: {{error}}.": "Whoops, looks like we got a problem: {{error}}." 120 | } 121 | -------------------------------------------------------------------------------- /pages/onedrive-vercel-index-oauth/step-3.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { useRouter } from 'next/router' 4 | import { useEffect, useState } from 'react' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { useTranslation, Trans } from 'next-i18next' 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations' 8 | 9 | import siteConfig from '../../config/site.config' 10 | import Navbar from '../../components/Navbar' 11 | import Footer from '../../components/Footer' 12 | 13 | import { getAuthPersonInfo, requestTokenWithAuthCode, sendTokenToServer } from '../../utils/oAuthHandler' 14 | import { LoadingIcon } from '../../components/Loading' 15 | 16 | export default function OAuthStep3({ accessToken, expiryTime, refreshToken, error, description, errorUri }) { 17 | const router = useRouter() 18 | const [expiryTimeLeft, setExpiryTimeLeft] = useState(expiryTime) 19 | 20 | const { t } = useTranslation() 21 | 22 | useEffect(() => { 23 | if (!expiryTimeLeft) return 24 | 25 | const intervalId = setInterval(() => { 26 | setExpiryTimeLeft(expiryTimeLeft - 1) 27 | }, 1000) 28 | 29 | return () => clearInterval(intervalId) 30 | }, [expiryTimeLeft]) 31 | 32 | const [buttonContent, setButtonContent] = useState( 33 |
34 | {t('Store tokens')} 35 |
36 | ) 37 | const [buttonError, setButtonError] = useState(false) 38 | 39 | const sendAuthTokensToServer = async () => { 40 | setButtonError(false) 41 | setButtonContent( 42 |
43 | {t('Storing tokens')} 44 |
45 | ) 46 | 47 | // verify identity of the authenticated user with the Microsoft Graph API 48 | const { data, status } = await getAuthPersonInfo(accessToken) 49 | if (status !== 200) { 50 | setButtonError(true) 51 | setButtonContent( 52 |
53 | {t('Error validating identify, restart')} 54 |
55 | ) 56 | return 57 | } 58 | if (data.userPrincipalName !== siteConfig.userPrincipalName) { 59 | setButtonError(true) 60 | setButtonContent( 61 |
62 | {t('Do not pretend to be the site owner')} 63 |
64 | ) 65 | return 66 | } 67 | 68 | await sendTokenToServer(accessToken, refreshToken, expiryTime) 69 | .then(() => { 70 | setButtonError(false) 71 | setButtonContent( 72 |
73 | {t('Stored! Going home...')} 74 |
75 | ) 76 | setTimeout(() => { 77 | router.push('/') 78 | }, 2000) 79 | }) 80 | .catch(_ => { 81 | setButtonError(true) 82 | setButtonContent( 83 |
84 | {t('Error storing the token')} 85 |
86 | ) 87 | }) 88 | } 89 | 90 | return ( 91 |
92 | 93 | {t('OAuth Step 3 - {{title}}', { title: siteConfig.title })} 94 | 95 | 96 |
97 | 98 | 99 |
100 |
101 |
102 | fabulous celebration 109 |
110 |

111 | {t('Welcome to your new onedrive-vercel-index 🎉')} 112 |

113 | 114 |

{t('Step 3/3: Get access and refresh tokens')}

115 | {error ? ( 116 |
117 |

118 | 119 | 120 | {t('Whoops, looks like we got a problem: {{error}}.', { 121 | // t('No auth code present') 122 | error: t(error), 123 | })} 124 | 125 |

126 |

127 | { 128 | // t('Where is the auth code? Did you follow step 2 you silly donut?') 129 | t(description) 130 | } 131 |

132 | {errorUri && ( 133 |

134 | 135 | Check out{' '} 136 | 142 | {/* eslint-disable-next-line react/no-unescaped-entities */} 143 | Microsoft's official explanation 144 | {' '} 145 | on the error message. 146 | 147 |

148 | )} 149 |
150 | 158 |
159 |
160 | ) : ( 161 |
162 |

{t('Success! The API returned what we needed.')}

163 |
    164 | {accessToken && ( 165 |
  1. 166 | {' '} 167 | 168 | {t('Acquired access_token: ')} 169 | {`${accessToken.substring(0, 60)}...`} 170 | 171 |
  2. 172 | )} 173 | {refreshToken && ( 174 |
  3. 175 | {' '} 176 | 177 | {t('Acquired refresh_token: ')} 178 | {`${refreshToken.substring(0, 60)}...`} 179 | 180 |
  4. 181 | )} 182 |
183 | 184 |

185 | {' '} 186 | {t('These tokens may take a few seconds to populate after you click the button below. ') + 187 | t('If you go back home and still see the welcome page telling you to re-authenticate, ') + 188 | t('revisit home and do a hard refresh.')} 189 |

190 |

191 | {t( 192 | 'Final step, click the button below to store these tokens persistently before they expire after {{minutes}} minutes {{seconds}} seconds. ', 193 | { 194 | minutes: Math.floor(expiryTimeLeft / 60), 195 | seconds: expiryTimeLeft - Math.floor(expiryTimeLeft / 60) * 60, 196 | } 197 | ) + 198 | t( 199 | "Don't worry, after storing them, onedrive-vercel-index will take care of token refreshes and updates after your site goes live." 200 | )} 201 |

202 | 203 |
204 | 214 |
215 |
216 | )} 217 |
218 |
219 |
220 | 221 |
223 | ) 224 | } 225 | 226 | export async function getServerSideProps({ query, locale }) { 227 | const { authCode } = query 228 | 229 | // Return if no auth code is present 230 | if (!authCode) { 231 | return { 232 | props: { 233 | error: 'No auth code present', 234 | description: 'Where is the auth code? Did you follow step 2 you silly donut?', 235 | ...(await serverSideTranslations(locale, ['common'])), 236 | }, 237 | } 238 | } 239 | 240 | const response = await requestTokenWithAuthCode(authCode) 241 | 242 | // If error response, return invalid 243 | if ('error' in response) { 244 | return { 245 | props: { 246 | error: response.error, 247 | description: response.errorDescription, 248 | errorUri: response.errorUri, 249 | ...(await serverSideTranslations(locale, ['common'])), 250 | }, 251 | } 252 | } 253 | 254 | const { expiryTime, accessToken, refreshToken } = response 255 | 256 | return { 257 | props: { 258 | error: null, 259 | expiryTime, 260 | accessToken, 261 | refreshToken, 262 | ...(await serverSideTranslations(locale, ['common'])), 263 | }, 264 | } 265 | } 266 | --------------------------------------------------------------------------------