import { posix as pathPosix } from 'path' import type { NextApiRequest, NextApiResponse } from 'next' import axios from 'axios' import apiConfig from '../../config/api.config' import siteConfig from '../../config/site.config' import { revealObfuscatedToken } from '../../utils/oAuthHandler' import { compareHashedToken } from '../../utils/protectedRouteHandler' import { getOdAuthTokens, storeOdAuthTokens } from '../../utils/odAuthTokenStore' import { runCorsMiddleware } from './raw' const basePath = pathPosix.resolve('/', siteConfig.baseDirectory) const clientSecret = revealObfuscatedToken(apiConfig.obfuscatedClientSecret) /** * Encode the path of the file relative to the base directory * * @param path Relative path of the file to the base directory * @returns Absolute path of the file inside OneDrive */ export function encodePath(path: string): string { let encodedPath = pathPosix.join(basePath, path) if (encodedPath === '/' || encodedPath === '') { return '' } encodedPath = encodedPath.replace(/\/$/, '') return `:${encodeURIComponent(encodedPath)}` } /** * Fetch the access token from Redis storage and check if the token requires a renew * * @returns Access token for OneDrive API */ export async function getAccessToken(): Promise { const { accessToken, refreshToken } = await getOdAuthTokens() // Return in storage access token if it is still valid if (typeof accessToken === 'string') { console.log('Fetch access token from storage.') return accessToken } // Return empty string if no refresh token is stored, which requires the application to be re-authenticated if (typeof refreshToken !== 'string') { console.log('No refresh token, return empty access token.') return '' } // Fetch new access token with in storage refresh token const body = new URLSearchParams() body.append('client_id', apiConfig.clientId) body.append('redirect_uri', apiConfig.redirectUri) body.append('client_secret', clientSecret) body.append('refresh_token', refreshToken) body.append('grant_type', 'refresh_token') const resp = await axios.post(apiConfig.authApi, body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) if ('access_token' in resp.data && 'refresh_token' in resp.data) { const { expires_in, access_token, refresh_token } = resp.data await storeOdAuthTokens({ accessToken: access_token, accessTokenExpiry: parseInt(expires_in), refreshToken: refresh_token, }) console.log('Fetch new access token with stored refresh token.') return access_token } return '' } /** * Match protected routes in site config to get path to required auth token * @param path Path cleaned in advance * @returns Path to required auth token. If not required, return empty string. */ export function getAuthTokenPath(path: string) { // Ensure trailing slashes to compare paths component by component. Same for protectedRoutes. // Since OneDrive ignores case, lower case before comparing. Same for protectedRoutes. path = path.toLowerCase() + '/' const protectedRoutes = siteConfig.protectedRoutes as string[] let authTokenPath = '' for (let r of protectedRoutes) { if (typeof r !== 'string') continue r = r.toLowerCase().replace(/\/$/, '') + '/' if (path.startsWith(r)) { authTokenPath = `${r}.password` break } } return authTokenPath } /** * Handles protected route authentication: * - Match the cleanPath against an array of user defined protected routes * - If a match is found: * - 1. Download the .password file stored inside the protected route and parse its contents * - 2. Check if the od-protected-token header is present in the request * - The request is continued only if these two contents are exactly the same * * @param cleanPath Sanitised directory path, used for matching whether route is protected * @param accessToken OneDrive API access token * @param req Next.js request object * @param res Next.js response object */ export async function checkAuthRoute( cleanPath: string, accessToken: string, odTokenHeader: string ): Promise<{ code: 200 | 401 | 404 | 500; message: string }> { // Handle authentication through .password const authTokenPath = getAuthTokenPath(cleanPath) // Fetch password from remote file content if (authTokenPath === '') { return { code: 200, message: '' } } try { const token = await axios.get(`${apiConfig.driveApi}/root${encodePath(authTokenPath)}`, { headers: { Authorization: `Bearer ${accessToken}` }, params: { select: '@microsoft.graph.downloadUrl,file', }, }) // Handle request and check for header 'od-protected-token' const odProtectedToken = await axios.get(token.data['@microsoft.graph.downloadUrl']) // console.log(odTokenHeader, odProtectedToken.data.trim()) if ( !compareHashedToken({ odTokenHeader: odTokenHeader, dotPassword: odProtectedToken.data.toString(), }) ) { return { code: 401, message: 'Password required.' } } } catch (error: any) { // Password file not found, fallback to 404 if (error?.response?.status === 404) { return { code: 404, message: "You didn't set a password." } } else { return { code: 500, message: 'Internal server error.' } } } return { code: 200, message: 'Authenticated.' } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { // If method is POST, then the API is called by the client to store acquired tokens if (req.method === 'POST') { const { obfuscatedAccessToken, accessTokenExpiry, obfuscatedRefreshToken } = req.body const accessToken = revealObfuscatedToken(obfuscatedAccessToken) const refreshToken = revealObfuscatedToken(obfuscatedRefreshToken) if (typeof accessToken !== 'string' || typeof refreshToken !== 'string') { res.status(400).send('Invalid request body') return } await storeOdAuthTokens({ accessToken, accessTokenExpiry, refreshToken }) res.status(200).send('OK') return } // If method is GET, then the API is a normal request to the OneDrive API for files or folders const { path = '/', raw = false, next = '', sort = '' } = req.query // Set edge function caching for faster load times, check docs: // https://vercel.com/docs/concepts/functions/edge-caching res.setHeader('Cache-Control', apiConfig.cacheControlHeader) // Sometimes the path parameter is defaulted to '[...path]' which we need to handle if (path === '[...path]') { res.status(400).json({ error: 'No path specified.' }) return } // If the path is not a valid path, return 400 if (typeof path !== 'string') { res.status(400).json({ error: 'Path query invalid.' }) return } // Besides normalizing and making absolute, trailing slashes are trimmed const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path)).replace(/\/$/, '') // Validate sort param if (typeof sort !== 'string') { res.status(400).json({ error: 'Sort query invalid.' }) return } const accessToken = await getAccessToken() // Return error 403 if access_token is empty if (!accessToken) { res.status(403).json({ error: 'No access token.' }) return } // Handle protected routes authentication const { code, message } = await checkAuthRoute(cleanPath, accessToken, req.headers['od-protected-token'] as string) // Status code other than 200 means user has not authenticated yet if (code !== 200) { res.status(code).json({ error: message }) return } // If message is empty, then the path is not protected. // Conversely, protected routes are not allowed to serve from cache. if (message !== '') { res.setHeader('Cache-Control', 'no-cache') } const requestPath = encodePath(cleanPath) // Handle response from OneDrive API const requestUrl = `${apiConfig.driveApi}/root${requestPath}` // Whether path is root, which requires some special treatment const isRoot = requestPath === '' // Go for file raw download link, add CORS headers, and redirect to @microsoft.graph.downloadUrl // (kept here for backwards compatibility, and cache headers will be reverted to no-cache) if (raw) { await runCorsMiddleware(req, res) res.setHeader('Cache-Control', 'no-cache') const { data } = await axios.get(requestUrl, { headers: { Authorization: `Bearer ${accessToken}` }, params: { // OneDrive international version fails when only selecting the downloadUrl (what a stupid bug) select: 'id,@microsoft.graph.downloadUrl', }, }) if ('@microsoft.graph.downloadUrl' in data) { res.redirect(data['@microsoft.graph.downloadUrl']) } else { res.status(404).json({ error: 'No download url found.' }) } return } // Querying current path identity (file or folder) and follow up query childrens in folder try { const { data: identityData } = await axios.get(requestUrl, { headers: { Authorization: `Bearer ${accessToken}` }, params: { select: 'name,size,id,lastModifiedDateTime,folder,file,video,image', }, }) if ('folder' in identityData) { const { data: folderData } = await axios.get(`${requestUrl}${isRoot ? '' : ':'}/children`, { headers: { Authorization: `Bearer ${accessToken}` }, params: { ...{ select: 'name,size,id,lastModifiedDateTime,folder,file,video,image', $top: siteConfig.maxItems, }, ...(next ? { $skipToken: next } : {}), ...(sort ? { $orderby: sort } : {}), }, }) // Extract next page token from full @odata.nextLink const nextPage = folderData['@odata.nextLink'] ? folderData['@odata.nextLink'].match(/&\$skiptoken=(.+)/i)[1] : null // Return paging token if specified if (nextPage) { res.status(200).json({ folder: folderData, next: nextPage }) } else { res.status(200).json({ folder: folderData }) } return } res.status(200).json({ file: identityData }) return } catch (error: any) { res.status(error?.response?.code ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' }) return } }