From 38d24146fa8021d4c59a41d20ca7e1bf16ada57f Mon Sep 17 00:00:00 2001 From: spencerwooo Date: Fri, 31 Dec 2021 03:44:03 +0800 Subject: [PATCH] oauth inside main project, with public client id and secret --- components/FileListing.tsx | 8 + config/api.json | 4 +- next.config.js | 2 + package-lock.json | 122 ++++++++- package.json | 3 + pages/_app.tsx | 12 +- pages/api/index.ts | 253 ++++++++++--------- pages/onedrive-vercel-index-oauth/step-1.tsx | 129 ++++++++++ pages/onedrive-vercel-index-oauth/step-2.tsx | 103 ++++++++ pages/onedrive-vercel-index-oauth/step-3.tsx | 148 +++++++++++ tailwind.config.js | 4 +- utils/accessTokenHandler.ts | 85 +++++++ 12 files changed, 749 insertions(+), 124 deletions(-) create mode 100644 pages/onedrive-vercel-index-oauth/step-1.tsx create mode 100644 pages/onedrive-vercel-index-oauth/step-2.tsx create mode 100644 pages/onedrive-vercel-index-oauth/step-3.tsx create mode 100644 utils/accessTokenHandler.ts diff --git a/components/FileListing.tsx b/components/FileListing.tsx index 74a25d6..889a282 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -179,6 +179,14 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) = const { data, error, size, setSize } = useProtectedSWRInfinite(path) if (error) { + console.log(error) + + // If error includes 403 which means the user has not completed initial setup, redirect to OAuth page + if (error.message.includes('403')) { + router.push('/onedrive-vercel-index-oauth/step-1') + return
+ } + return (
{error.message.includes('401') ? : } diff --git a/config/api.json b/config/api.json index bd4348e..5414703 100644 --- a/config/api.json +++ b/config/api.json @@ -1,7 +1,9 @@ { "clientId": "d87bcc39-1750-4ca0-ad54-f8d0efbb2735", + "obfuscatedClientSecret": "U2FsdGVkX1830zo3/pFDqaBCVBb37iLw3WnBDWGF9GIB2f4apzv0roemp8Y+iIxI3Ih5ecyukqELQEGzZlYiWg==", "redirectUri": "http://localhost", "base": "/Public", "authApi": "https://login.microsoftonline.com/common/oauth2/v2.0/token", - "driveApi": "https://graph.microsoft.com/v1.0/me/drive" + "driveApi": "https://graph.microsoft.com/v1.0/me/drive", + "scope": "Files.Read.All Files.ReadWrite.All offline_access" } diff --git a/next.config.js b/next.config.js index 0f1844e..dfd9420 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,7 @@ module.exports = { webpack: (config) => { + config.resolve.fallback = { fs: false, path: false, stream: false, constants: false }; + // load worker files as a urls with `file-loader` config.module.rules.unshift({ test: /pdf\.worker\.(min\.)?js/, diff --git a/package-lock.json b/package-lock.json index 2473d4f..00b9f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "crypto-js": "^4.1.1", "emoji-regex": "^9.2.2", "jszip": "^3.7.1", + "keyv": "^4.0.4", + "keyv-file": "^0.2.0", "next": "^12.0.7", "nextjs-progressbar": "^0.0.13", "preview-office-docs": "^1.0.2", @@ -39,6 +41,7 @@ }, "devDependencies": { "@types/crypto-js": "^4.0.2", + "@types/keyv": "^3.1.3", "@types/prismjs": "^1.16.5", "@types/react": "17.0.11", "@types/react-copy-to-clipboard": "^5.0.0", @@ -1132,6 +1135,15 @@ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.11.1.tgz", "integrity": "sha512-DUlIj2nk0YnJdlWgsFuVKcX27MLW0KbKmGVoUHmFr+74FYYNUDAaj9ZqTADvsbE8rfxuVmSFc7KczYn5Y09ozg==" }, + "node_modules/@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/localforage": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", @@ -3273,6 +3285,16 @@ "url": "https://www.patreon.com/infusion" } }, + "node_modules/fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4435,6 +4457,11 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -4474,6 +4501,14 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz", @@ -4536,6 +4571,24 @@ "katex": "cli.js" } }, + "node_modules/keyv": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz", + "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/keyv-file": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-0.2.0.tgz", + "integrity": "sha512-zUQ11eZRmilEUpV1gJSj8mBAHjyXpleQo1iCS0khb+GFRhiPfwavWgn4eDUKNlOyMZzmExnISl8HE1hNbim0gw==", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^4.0.1", + "tslib": "^1.9.3" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", @@ -7390,8 +7443,7 @@ "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -7600,6 +7652,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8643,6 +8703,15 @@ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.11.1.tgz", "integrity": "sha512-DUlIj2nk0YnJdlWgsFuVKcX27MLW0KbKmGVoUHmFr+74FYYNUDAaj9ZqTADvsbE8rfxuVmSFc7KczYn5Y09ozg==" }, + "@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/localforage": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", @@ -10308,6 +10377,16 @@ "integrity": "sha512-MHOhvvxHTfRFpF1geTK9czMIZ6xclsEor2wkIGYYq+PxcQqT7vStJqjhe6S1TenZrMZzo+wlqOufBDVepUEgPg==", "dev": true }, + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11107,6 +11186,11 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "peer": true }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -11140,6 +11224,14 @@ "minimist": "^1.2.5" } }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsx-ast-utils": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz", @@ -11198,6 +11290,24 @@ "commander": "^6.0.0" } }, + "keyv": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz", + "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "keyv-file": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-0.2.0.tgz", + "integrity": "sha512-zUQ11eZRmilEUpV1gJSj8mBAHjyXpleQo1iCS0khb+GFRhiPfwavWgn4eDUKNlOyMZzmExnISl8HE1hNbim0gw==", + "requires": { + "debug": "^4.1.1", + "fs-extra": "^4.0.1", + "tslib": "^1.9.3" + } + }, "language-subtag-registry": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", @@ -13341,8 +13451,7 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "tsutils": { "version": "3.21.0", @@ -13484,6 +13593,11 @@ } } }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index b05b829..a34a02c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "crypto-js": "^4.1.1", "emoji-regex": "^9.2.2", "jszip": "^3.7.1", + "keyv": "^4.0.4", + "keyv-file": "^0.2.0", "next": "^12.0.7", "nextjs-progressbar": "^0.0.13", "preview-office-docs": "^1.0.2", @@ -40,6 +42,7 @@ }, "devDependencies": { "@types/crypto-js": "^4.0.2", + "@types/keyv": "^3.1.3", "@types/prismjs": "^1.16.5", "@types/react": "17.0.11", "@types/react-copy-to-clipboard": "^5.0.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index d4c1290..98fa744 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -20,6 +20,7 @@ import { faArrowAltCircleDown, faTrashAlt, faEnvelope, + faCheckCircle, } from '@fortawesome/free-regular-svg-icons' import { faPlus, @@ -36,6 +37,10 @@ import { faSignOutAlt, faCloud, faChevronCircleDown, + faExternalLinkAlt, + faExclamationCircle, + faExclamationTriangle, + faHome, } from '@fortawesome/free-solid-svg-icons' import * as Icons from '@fortawesome/free-brands-svg-icons' @@ -78,7 +83,12 @@ library.add( faEnvelope, faCloud, faChevronCircleDown, - ...iconList, + faExternalLinkAlt, + faExclamationCircle, + faExclamationTriangle, + faHome, + faCheckCircle, + ...iconList ) function MyApp({ Component, pageProps }: AppProps) { diff --git a/pages/api/index.ts b/pages/api/index.ts index 64ad414..77609a5 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -1,9 +1,13 @@ -import axios from 'axios' -import type { NextApiRequest, NextApiResponse } from 'next' import { posix as pathPosix } from 'path' +import type { NextApiRequest, NextApiResponse } from 'next' +import axios from 'axios' +import Keyv from 'keyv' +import { KeyvFile } from 'keyv-file' + import apiConfig from '../../config/api.json' import siteConfig from '../../config/site.json' +import { revealObfuscatedToken } from '../../utils/accessTokenHandler' import { compareHashedToken } from '../../utils/protectedRouteHandler' const basePath = pathPosix.resolve('/', apiConfig.base) @@ -16,19 +20,28 @@ const encodePath = (path: string) => { return `:${encodeURIComponent(encodedPath)}` } +const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) + // Store access token in memory, cuz Vercel doesn't provide key-value storage natively let _access_token = '' +let _refresh_token = '' const getAccessToken = async () => { if (_access_token) { - console.log('Fetch token from memory.') + console.log('Fetch access token from memory.') return _access_token } + // Return if refresh_token is empty + if (!_refresh_token) { + console.log('No refresh token, return empty access token.') + return '' + } + const body = new URLSearchParams() body.append('client_id', apiConfig.clientId) body.append('redirect_uri', apiConfig.redirectUri) - body.append('client_secret', process.env.CLIENT_SECRET ? process.env.CLIENT_SECRET : '') - body.append('refresh_token', process.env.REFRESH_TOKEN ? process.env.REFRESH_TOKEN : '') + body.append('client_secret', clientSecret) + body.append('refresh_token', _refresh_token) body.append('grant_type', 'refresh_token') const resp = await axios.post(apiConfig.authApi, body, { @@ -45,124 +58,130 @@ const getAccessToken = async () => { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { path = '/', raw = false, next = '' } = req.query + + // Sometimes the path parameter is defaulted to '[...path]' which we need to handle if (path === '[...path]') { res.status(400).json({ error: 'No path specified.' }) return } - - if (typeof path === 'string') { - const accessToken = await getAccessToken() - - // Handle authentication through .password - const protectedRoutes = siteConfig.protectedRoutes - let authTokenPath = '' - for (const r of protectedRoutes) { - if (path.startsWith(r)) { - authTokenPath = `${r}/.password` - break - } - } - - // Fetch password from remote file content - if (authTokenPath !== '') { - try { - const token = await axios.get(`${apiConfig.driveApi}/root${encodePath(authTokenPath)}`, { - headers: { Authorization: `Bearer ${accessToken}` }, - params: { - select: '@microsoft.graph.downloadUrl,file', - }, - }) - - // Handle request and check for header 'od-protected-token' - const odProtectedToken = await axios.get(token.data['@microsoft.graph.downloadUrl']) - // console.log(req.headers['od-protected-token'], odProtectedToken.data.trim()) - - if ( - !compareHashedToken({ - odTokenHeader: req.headers['od-protected-token'] as string, - dotPassword: odProtectedToken.data, - }) - ) { - res.status(401).json({ error: 'Password required for this folder.' }) - return - } - } catch (error: any) { - // Password file not found, fallback to 404 - if (error.response.status === 404) { - res.status(404).json({ error: "You didn't set a password for your protected folder." }) - } - res.status(500).end() - return - } - } - - const requestPath = encodePath(path) - // Handle response from OneDrive API - const requestUrl = `${apiConfig.driveApi}/root${requestPath}` - // Whether path is root, which requires some special treatment - const isRoot = requestPath === '' - - // Go for file raw download link and query with only temporary link parameter - if (raw) { - const { data } = await axios.get(requestUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - params: { - select: '@microsoft.graph.downloadUrl,folder,file', - }, - }) - - if ('folder' in data) { - res.status(400).json({ error: "Folders doesn't have raw download urls." }) - return - } - if ('file' in data) { - res.redirect(data['@microsoft.graph.downloadUrl']) - return - } - } - - // Querying current path identity (file or folder) and follow up query childrens in folder - // console.log(accessToken) - - const { data: identityData } = await axios.get(requestUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - params: { - select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', - }, - }) - - if ('folder' in identityData) { - const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/children`, { - headers: { Authorization: `Bearer ${accessToken}` }, - params: next - ? { - select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', - top: siteConfig.maxItems, - $skipToken: next, - } - : { - select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', - top: siteConfig.maxItems, - }, - }) - - // Extract next page token from full @odata.nextLink - const nextPage = folderData['@odata.nextLink'] - ? folderData['@odata.nextLink'].match(/&\$skiptoken=(.+)/i)[1] - : null - - // Return paging token if specified - if (nextPage) { - res.status(200).json({ folder: folderData, next: nextPage }) - } else { - res.status(200).json({ folder: folderData }) - } - return - } - res.status(200).json({ file: identityData }) + // If the path is not a valid path, return 400 + if (typeof path !== 'string') { + res.status(400).json({ error: 'Path query invalid.' }) return } - res.status(404).json({ error: 'Path query invalid.' }) + const accessToken = await getAccessToken() + + // Return error 403 if access_token is empty + if (!accessToken) { + res.status(403).json({ error: 'No access token.' }) + return + } + + // Handle authentication through .password + const protectedRoutes = siteConfig.protectedRoutes + let authTokenPath = '' + for (const r of protectedRoutes) { + if (path.startsWith(r)) { + authTokenPath = `${r}/.password` + break + } + } + + // Fetch password from remote file content + if (authTokenPath !== '') { + try { + const token = await axios.get(`${apiConfig.driveApi}/root${encodePath(authTokenPath)}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { + select: '@microsoft.graph.downloadUrl,file', + }, + }) + + // Handle request and check for header 'od-protected-token' + const odProtectedToken = await axios.get(token.data['@microsoft.graph.downloadUrl']) + // console.log(req.headers['od-protected-token'], odProtectedToken.data.trim()) + + if ( + !compareHashedToken({ + odTokenHeader: req.headers['od-protected-token'] as string, + dotPassword: odProtectedToken.data, + }) + ) { + res.status(401).json({ error: 'Password required for this folder.' }) + return + } + } catch (error: any) { + // Password file not found, fallback to 404 + if (error.response.status === 404) { + res.status(404).json({ error: "You didn't set a password for your protected folder." }) + } + res.status(500).end() + return + } + } + + const requestPath = encodePath(path) + // Handle response from OneDrive API + const requestUrl = `${apiConfig.driveApi}/root${requestPath}` + // Whether path is root, which requires some special treatment + const isRoot = requestPath === '' + + // Go for file raw download link and query with only temporary link parameter + if (raw) { + const { data } = await axios.get(requestUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { + select: '@microsoft.graph.downloadUrl,folder,file', + }, + }) + + if ('folder' in data) { + res.status(400).json({ error: "Folders doesn't have raw download urls." }) + return + } + if ('file' in data) { + res.redirect(data['@microsoft.graph.downloadUrl']) + return + } + } + + // Querying current path identity (file or folder) and follow up query childrens in folder + // console.log(accessToken) + + const { data: identityData } = await axios.get(requestUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: { + select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', + }, + }) + + if ('folder' in identityData) { + const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/children`, { + headers: { Authorization: `Bearer ${accessToken}` }, + params: next + ? { + select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', + top: siteConfig.maxItems, + $skipToken: next, + } + : { + select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file', + top: siteConfig.maxItems, + }, + }) + + // Extract next page token from full @odata.nextLink + const nextPage = folderData['@odata.nextLink'] ? folderData['@odata.nextLink'].match(/&\$skiptoken=(.+)/i)[1] : null + + // Return paging token if specified + if (nextPage) { + res.status(200).json({ folder: folderData, next: nextPage }) + } else { + res.status(200).json({ folder: folderData }) + } + return + } + res.status(200).json({ file: identityData }) return } diff --git a/pages/onedrive-vercel-index-oauth/step-1.tsx b/pages/onedrive-vercel-index-oauth/step-1.tsx new file mode 100644 index 0000000..7dc95b0 --- /dev/null +++ b/pages/onedrive-vercel-index-oauth/step-1.tsx @@ -0,0 +1,129 @@ +import Head from 'next/head' +import router from 'next/router' + +import siteConfig from '../../config/site.json' +import apiConfig from '../../config/api.json' +import Navbar from '../../components/Navbar' +import Footer from '../../components/Footer' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +export default function OAuthStep1() { + return ( +
+ + {`OAuth Step 1 - ${siteConfig.title}`} + + +
+ + +
+
+

Welcome to your new onedrive-vercel-index 🎉

+ +

+ Authorisation is required as no valid{' '} + access_token or{' '} + refresh_token is + present on this deployed instance. +

+ +

Step 1: Preparations

+

+ Check the following configurations (especially client_id and{' '} + client_secret (obfuscated)) and see if they match the official + ones specified in the documentation of{' '} + + onedrive-vercel-index + {' '} + before proceeding with authorising this application with your own Microsoft account. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ CLIENT_ID + + {apiConfig.clientId} +
+ CLIENT_SECRET* + + {apiConfig.obfuscatedClientSecret} +
+ REDIRECT_URI + + {apiConfig.redirectUri} +
+ Auth API URL + + {apiConfig.authApi} +
+ Drive API URL + + {apiConfig.driveApi} +
+ API Scope + + {apiConfig.scope} +
+
+ +

+ If you see anything + missing or incorrect, you need to reconfigure /config/api.json{' '} + and redeploy this instance. +

+

+ ️ If you are previewing + this locally, you would need to reauthorise yourself when you eventually deploy the site on Vercel. +

+ +
+ +
+
+
+
+ +
+
+ ) +} diff --git a/pages/onedrive-vercel-index-oauth/step-2.tsx b/pages/onedrive-vercel-index-oauth/step-2.tsx new file mode 100644 index 0000000..578f28d --- /dev/null +++ b/pages/onedrive-vercel-index-oauth/step-2.tsx @@ -0,0 +1,103 @@ +import Head from 'next/head' +import router from 'next/router' +import { useState } from 'react' + +import siteConfig from '../../config/site.json' +import Navbar from '../../components/Navbar' +import Footer from '../../components/Footer' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { extractAuthCodeFromRedirected, generateAuthorisationUrl } from '../../utils/accessTokenHandler' + +export default function OAuthStep2() { + const [oAuthRedirectedUrl, setOAuthRedirectedUrl] = useState('') + const [authCode, setAuthCode] = useState('') + const oAuthUrl = generateAuthorisationUrl() + + return ( +
+ + {`OAuth Step 2 - ${siteConfig.title}`} + + +
+ + +
+
+

Welcome to your new onedrive-vercel-index 🎉

+ +

+ Authorisation is required as no valid{' '} + access_token or{' '} + refresh_token is + present on this deployed instance. +

+ +

Step 2: Get authorisation code

+

The OAuth URL has been generated for you:

+
{ + window.open(oAuthUrl) + }} + > +
+ +
+
+                {oAuthUrl}
+              
+
+

+ Click on the link to get the{' '} + authorisation code. Your browser will + open a new tab to Microsoft's account login page. Authenticate with your Microsoft account and copy + the redirected URL down below. +

+ + { + setOAuthRedirectedUrl(e.target.value) + setAuthCode(extractAuthCodeFromRedirected(e.target.value)) + }} + /> + +

The authorisation code extracted is:

+

+ {authCode || Waiting for code...} +

+ +

+ {authCode + ? '✅ You can now proceed onto the next step: requesting your access token and refresh token.' + : '❌ No valid code extracted.'} +

+ +
+ +
+
+
+
+ +
+
+ ) +} diff --git a/pages/onedrive-vercel-index-oauth/step-3.tsx b/pages/onedrive-vercel-index-oauth/step-3.tsx new file mode 100644 index 0000000..419645d --- /dev/null +++ b/pages/onedrive-vercel-index-oauth/step-3.tsx @@ -0,0 +1,148 @@ +import Head from 'next/head' +import router from 'next/router' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import siteConfig from '../../config/site.json' +import Navbar from '../../components/Navbar' +import Footer from '../../components/Footer' +import { requestTokenWithAuthCode } from '../../utils/accessTokenHandler' + +export default function OAuthStep3({ accessToken, refreshToken, error, description, errorUri }) { + return ( +
+ + {`OAuth Step 3 - ${siteConfig.title}`} + + +
+ + +
+
+

Welcome to your new onedrive-vercel-index 🎉

+ +

+ Authorisation is required as no valid{' '} + access_token or{' '} + refresh_token is + present on this deployed instance. +

+ +

Step 3: Get access and refresh tokens

+ {error ? ( +
+

+ + Whoops, looks like we got a problem: {error}. +

+

+ {description} +

+ {errorUri && ( +

+ Check out{' '} + + Microsoft's official explanation + {' '} + on the error message. +

+ )} +
+ +
+
+ ) : ( +
+

Success, the API returned what we needed.

+
    + {accessToken && ( +
  1. + {' '} + + Acquired access_token:{' '} + {`${accessToken.substring(0, 30)}...`}. + +
  2. + )} + {refreshToken && ( +
  3. + {' '} + + Acquired refresh_token:{' '} + {`${refreshToken.substring(0, 50)}...`}. + +
  4. + )} +
+ +

Your onedrive-vercel-index should be up and running. Go back home to find out.

+ +
+ +
+
+ )} +
+
+
+ +
+ ) +} + +export async function getServerSideProps({ query }) { + const { authCode } = query + + // Return if no auth code is present + if (!authCode) { + return { + props: { + error: 'No auth code present', + description: 'Where is the auth code??? Did you follow step 2 you silly donut?', + }, + } + } + + const response = await requestTokenWithAuthCode(authCode) + + // If error response, return invalid + if ('error' in response) { + return { + props: { + error: response.error, + description: response.errorDescription, + errorUri: response.errorUri, + }, + } + } + + const { expiryTime, accessToken, refreshToken } = response + return { + props: { + error: null, + expiryTime, + accessToken, + refreshToken, + }, + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 2123b73..c88d993 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -19,7 +19,9 @@ module.exports = { indigo: colors.indigo, purple: colors.purple, pink: colors.pink, - teal: colors.teal + teal: colors.teal, + cyan: colors.cyan, + orange: colors.orange, }, extend: { fontFamily: { diff --git a/utils/accessTokenHandler.ts b/utils/accessTokenHandler.ts new file mode 100644 index 0000000..5053f68 --- /dev/null +++ b/utils/accessTokenHandler.ts @@ -0,0 +1,85 @@ +import axios from 'axios' +import CryptoJS from 'crypto-js' +import Keyv from 'keyv' +import KeyvFile from 'keyv-file' + +import apiConfig from '../config/api.json' + +const AES_SECRET_KEY = 'onedrive-vercel-index' + +export function obfuscateToken(token: string): string { + // Encrypt token with AES + const encrypted = CryptoJS.AES.encrypt(token, AES_SECRET_KEY) + return encrypted.toString() +} + +export function revealObfuscatedToken(obfuscated: string): string { + // Decrypt SHA256 obfuscated token + const decrypted = CryptoJS.AES.decrypt(obfuscated, AES_SECRET_KEY) + return decrypted.toString(CryptoJS.enc.Utf8) +} + +export function generateAuthorisationUrl(): string { + const { clientId, redirectUri, authApi } = apiConfig + const authUrl = authApi.replace('/token', '/authorize') + + // Construct URL parameters for OAuth2 + const params = new URLSearchParams() + params.append('client_id', clientId) + params.append('redirect_uri', redirectUri) + params.append('response_type', 'code') + params.append('scope', 'files.readwrite offline_access') + params.append('response_mode', 'query') + + return `${authUrl}?${params.toString()}` +} + +export function extractAuthCodeFromRedirected(url: string): string { + // Return empty string if the url is not the defined redirect uri + if (!url.startsWith(apiConfig.redirectUri)) { + return '' + } + + // New URL search parameter + const params = new URLSearchParams(url.split('?')[1]) + return params.get('code') || '' +} + +export async function requestTokenWithAuthCode( + code: string +): Promise< + | { expiryTime: string; accessToken: string; refreshToken: string } + | { error: string; errorDescription: string; errorUri: string } +> { + const { clientId, redirectUri, authApi } = apiConfig + const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) + + // Construct URL parameters for OAuth2 + const params = new URLSearchParams() + params.append('client_id', clientId) + params.append('redirect_uri', redirectUri) + params.append('client_secret', clientSecret) + params.append('code', code) + params.append('grant_type', 'authorization_code') + + // Request access token + return axios + .post(authApi, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + .then(resp => { + const { expires_in, access_token, refresh_token } = resp.data + return { expiryTime: expires_in, accessToken: access_token, refreshToken: refresh_token } + }) + .catch(err => { + const { error, error_description, error_uri } = err.response.data + return { error, errorDescription: error_description, errorUri: error_uri } + }) +} + +export function storeTokens(accessToken: string, refreshToken: string) { + // We can safely leverage Vercel's /tmp directory, and persist the tokens with file-system based KV storage + const kv = new Keyv({ store: new KeyvFile(), namespace: 'onedrive-vercel-index' }) +}