Standalone raw file redirects (#428)

This commit is contained in:
Spencer Woo
2022-02-14 19:33:19 +08:00
committed by GitHub
parent 0fda1c93db
commit 9493ce9f3d
30 changed files with 365 additions and 185 deletions
+81 -67
View File
@@ -2,31 +2,17 @@ import { posix as pathPosix } from 'path'
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import Cors from 'cors'
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)
// CORS middleware for raw links: https://nextjs.org/docs/api-routes/api-middlewares
function runCorsMiddleware(req: NextApiRequest, res: NextApiResponse) {
const cors = Cors({ methods: ['GET', 'HEAD'] })
return new Promise((resolve, reject) => {
cors(req, res, result => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
/**
* Encode the path of the file relative to the base directory
*
@@ -107,6 +93,64 @@ export function getAuthTokenPath(path: string) {
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,
})
) {
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') {
@@ -119,11 +163,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return
}
await storeOdAuthTokens({
accessToken,
accessTokenExpiry,
refreshToken,
})
await storeOdAuthTokens({ accessToken, accessTokenExpiry, refreshToken })
res.status(200).send('OK')
return
}
@@ -155,43 +195,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return
}
// Handle authentication through .password
const authTokenPath = getAuthTokenPath(cleanPath)
// Fetch password from remote file content
if (authTokenPath !== '') {
// Don't server cached response for password protected folders
// 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')
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(cleanPath)
@@ -201,24 +215,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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: {
select: '@microsoft.graph.downloadUrl,folder,file',
select: '@microsoft.graph.downloadUrl',
},
})
if ('folder' in data) {
res.status(400).json({ error: "Folders doesn't have raw download urls." })
return
}
if ('file' in data) {
if ('@microsoft.graph.downloadUrl' in data) {
res.redirect(data['@microsoft.graph.downloadUrl'])
return
} 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
@@ -226,7 +240,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { data: identityData } = await axios.get(requestUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file,video,image',
select: 'name,size,id,lastModifiedDateTime,folder,file,video,image',
},
})
@@ -235,12 +249,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
headers: { Authorization: `Bearer ${accessToken}` },
params: next
? {
select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file,video,image',
select: 'name,size,id,lastModifiedDateTime,folder,file,video,image',
top: siteConfig.maxItems,
$skipToken: next,
}
: {
select: '@microsoft.graph.downloadUrl,name,size,id,lastModifiedDateTime,folder,file,video,image',
select: 'name,size,id,lastModifiedDateTime,folder,file,video,image',
top: siteConfig.maxItems,
},
})
@@ -261,7 +275,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(200).json({ file: identityData })
return
} catch (error: any) {
res.status(error.response.status).json({ error: error.response.data })
res.status(error?.response?.code ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
return
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
})
res.status(200).json(data)
} catch (error: any) {
res.status(error.response.status).json({ error: error.response.data })
res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
}
} else {
res.status(400).json({ error: 'Invalid driveItem ID.' })
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { default as indexHandler } from '..'
import { default as rawFileHandler } from '../raw'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
indexHandler(req, res)
rawFileHandler(req, res)
}
+81
View File
@@ -0,0 +1,81 @@
import { posix as pathPosix } from 'path'
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import Cors from 'cors'
import { driveApi } from '../../config/api.config'
import { encodePath, getAccessToken, checkAuthRoute } from '.'
// CORS middleware for raw links: https://nextjs.org/docs/api-routes/api-middlewares
export function runCorsMiddleware(req: NextApiRequest, res: NextApiResponse) {
const cors = Cors({ methods: ['GET', 'HEAD'] })
return new Promise((resolve, reject) => {
cors(req, res, result => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const accessToken = await getAccessToken()
if (!accessToken) {
res.status(403).json({ error: 'No access token.' })
return
}
const { path = '/', odpt = '' } = req.query
// Sometimes the path parameter is defaulted to '[...path]' which we need to handle
if (path === '[...path]') {
res.status(400).json({ error: 'No path specified.' })
return
}
// If the path is not a valid path, return 400
if (typeof path !== 'string') {
res.status(400).json({ error: 'Path query invalid.' })
return
}
const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path))
// Handle protected routes authentication
const odTokenHeader = (req.headers['od-protected-token'] as string) ?? odpt
const { code, message } = await checkAuthRoute(cleanPath, accessToken, odTokenHeader)
// Status code other than 200 means user has not authenticated yet
if (code !== 200) {
res.status(code).json({ error: message })
return
}
// If message is empty, then the path is not protected.
// Conversely, protected routes are not allowed to serve from cache.
if (message !== '') {
res.setHeader('Cache-Control', 'no-cache')
}
await runCorsMiddleware(req, res)
try {
// Handle response from OneDrive API
const requestUrl = `${driveApi}/root${encodePath(cleanPath)}`
const { data } = await axios.get(requestUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
select: '@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
} catch (error: any) {
res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
return
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
})
res.status(200).json(data.value)
} catch (error: any) {
res.status(error.response.status).json({ error: error.response.data })
res.status(error?.response?.status ?? 500).json({ error: error?.response?.data ?? 'Internal server error.' })
}
} else {
res.status(200).json([])
+18 -12
View File
@@ -5,19 +5,22 @@ import { posix as pathPosix } from 'path'
import axios from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import { encodePath, getAccessToken, getAuthTokenPath } from '.'
import { checkAuthRoute, encodePath, getAccessToken } from '.'
import apiConfig from '../../config/api.config'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Get access token from storage
const accessToken = await getAccessToken()
if (!accessToken) {
res.status(403).json({ error: 'No access token.' })
return
}
// Get item thumbnails by its path since we will later check if it is protected
const { path = '', size = 'medium' } = req.query
const { path = '', size = 'medium', odpt = '' } = req.query
// Set edge function caching for faster load times, check docs:
// Set edge function caching for faster load times, if route is not protected, check docs:
// https://vercel.com/docs/concepts/functions/edge-caching
res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
if (odpt === '') res.setHeader('Cache-Control', apiConfig.cacheControlHeader)
// Check whether the size is valid - must be one of 'large', 'medium', or 'small'
if (size !== 'large' && size !== 'medium' && size !== 'small') {
@@ -36,14 +39,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const cleanPath = pathPosix.resolve('/', pathPosix.normalize(path))
// Check if the path is protected
const authTokenPath = getAuthTokenPath(cleanPath)
// Currently protected paths are rejected to avoid file content leak
if (authTokenPath) {
res.status(404).json({ error: 'Protected pathes are not allowed.' })
const { code, message } = await checkAuthRoute(cleanPath, accessToken, odpt as string)
// Status code other than 200 means user has not authenticated yet
if (code !== 200) {
res.status(code).json({ error: message })
return
}
// If message is empty, then the path is not protected.
// Conversely, protected routes are not allowed to serve from cache.
if (message !== '') {
res.setHeader('Cache-Control', 'no-cache')
}
const requestPath = encodePath(cleanPath)
// Handle response from OneDrive API
@@ -63,7 +69,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(400).json({ error: "The item doesn't have a valid thumbnail." })
}
} catch (error: any) {
res.status(error.response.status).json({ error: error.response.data })
res.status(error?.response?.status).json({ error: error?.response?.data ?? 'Internal server error.' })
}
return
}