window.open(`potplayer://${file['@microsoft.graph.downloadUrl']}`)}
+ onClickCallback={() => window.open(`potplayer://${getBaseUrl()}/${videoUrl}`)}
btnText="PotPlayer"
btnImage="/players/potplayer.png"
/>
diff --git a/config/api.config.js b/config/api.config.js
index d5c8229..b3dee7a 100644
--- a/config/api.config.js
+++ b/config/api.config.js
@@ -29,7 +29,10 @@ module.exports = {
// unauthorised use of the proxied download feature - but that is disabled for now. So you can safely ignore this settings.
directLinkRegex: 'public[.].*[.]files[.]1drv[.]com',
- // Cache-Control header, check Vercel documentation for more details.
+ // Cache-Control header, check Vercel documentation for more details. The default settings imply:
+ // - max-age=0: no cache for your browser
+ // - s-maxage=0: cache is fresh for 60 seconds on the edge, after which it becomes stale
+ // - stale-while-revalidate: allow serving stale content while revalidating on the edge
// https://vercel.com/docs/concepts/edge-network/caching
- cacheControlHeader: 'max-age=0, s-maxage=3540, stale-while-revalidate=60'
+ cacheControlHeader: 'max-age=0, s-maxage=60, stale-while-revalidate',
}
diff --git a/pages/api/index.ts b/pages/api/index.ts
index e0090ef..16d26a0 100644
--- a/pages/api/index.ts
+++ b/pages/api/index.ts
@@ -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
}
}
diff --git a/pages/api/item.ts b/pages/api/item.ts
index 7884105..6456c79 100644
--- a/pages/api/item.ts
+++ b/pages/api/item.ts
@@ -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.' })
diff --git a/pages/api/name/[name].ts b/pages/api/name/[name].ts
index 53b366c..12eb124 100644
--- a/pages/api/name/[name].ts
+++ b/pages/api/name/[name].ts
@@ -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)
}
diff --git a/pages/api/raw.ts b/pages/api/raw.ts
new file mode 100644
index 0000000..e45965c
--- /dev/null
+++ b/pages/api/raw.ts
@@ -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
+ }
+}
diff --git a/pages/api/search.ts b/pages/api/search.ts
index 7f07c21..17ddc91 100644
--- a/pages/api/search.ts
+++ b/pages/api/search.ts
@@ -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([])
diff --git a/pages/api/thumbnail.ts b/pages/api/thumbnail.ts
index 48fd84c..fd0d014 100644
--- a/pages/api/thumbnail.ts
+++ b/pages/api/thumbnail.ts
@@ -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
}
diff --git a/pages/onedrive-vercel-index-oauth/step-2.tsx b/pages/onedrive-vercel-index-oauth/step-2.tsx
index 2bb55e9..70b7555 100644
--- a/pages/onedrive-vercel-index-oauth/step-2.tsx
+++ b/pages/onedrive-vercel-index-oauth/step-2.tsx
@@ -103,7 +103,7 @@ export default function OAuthStep2() {
{t('The authorisation code extracted is:')}
- {authCode || {t('Waiting for code...')}}
+ {authCode ?? {t('Waiting for code...')}}
diff --git a/types/index.d.ts b/types/index.d.ts
index 06162d0..e1adc0b 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,4 +1,4 @@
-// API response object for /api?path=, this may return either a file or a folder.
+// API response object for /api/?path=, this may return either a file or a folder.
// Pagination is also declared here with the 'next' parameter.
export type OdAPIResponse = { file?: OdFileObject; folder?: OdFolderObject; next?: string }
// A folder object returned from the OneDrive API. This contains the parameter 'value', which is an array of items
@@ -8,7 +8,6 @@ export type OdFolderObject = {
'@odata.context': string
'@odata.nextLink'?: string
value: Array<{
- '@microsoft.graph.downloadUrl': string
id: string
name: string
size: number
@@ -17,14 +16,11 @@ export type OdFolderObject = {
folder?: { childCount: number; view: { sortBy: string; sortOrder: 'ascending'; viewType: 'thumbnails' } }
image?: OdImageFile
video?: OdVideoFile
- // 'thumbnails@odata.context'?: string
- // thumbnails?: Array
}>
}
export type OdFolderChildren = OdFolderObject['value'][number]
// A file object returned from the OneDrive API. This object may contain 'video' if the file is a video.
export type OdFileObject = {
- '@microsoft.graph.downloadUrl': string
'@odata.context': string
name: string
size: number
@@ -33,8 +29,6 @@ export type OdFileObject = {
file: { mimeType: string; hashes: { quickXorHash: string; sha1Hash?: string; sha256Hash?: string } }
image?: OdImageFile
video?: OdVideoFile
- // 'thumbnails@odata.context'?: string
- // thumbnails?: Array
}
// A representation of a OneDrive image file. Some images do not return a width and height, so types are optional.
export type OdImageFile = {
@@ -59,7 +53,7 @@ export type OdThumbnail = {
medium: { height: number; width: number; url: string }
small: { height: number; width: number; url: string }
}
-// API response object for /api/search?q=. Likewise, this array of items may also contain either files or folders.
+// API response object for /api/search/?q=. Likewise, this array of items may also contain either files or folders.
export type OdSearchResult = Array<{
id: string
name: string
@@ -68,7 +62,7 @@ export type OdSearchResult = Array<{
path: string
parentReference: { id: string; name: string; path: string }
}>
-// API response object for /api/item?id={id}. This is primarily used for determining the path of the driveItem by ID.
+// API response object for /api/item/?id={id}. This is primarily used for determining the path of the driveItem by ID.
export type OdDriveItem = {
'@odata.context': string
'@odata.etag': string
diff --git a/utils/fetchOnMount.ts b/utils/fetchOnMount.ts
index 314639f..818db8e 100644
--- a/utils/fetchOnMount.ts
+++ b/utils/fetchOnMount.ts
@@ -1,22 +1,31 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
+import { getStoredToken } from './protectedRouteHandler'
-// Custom hook to axios get a URL or API endpoint on mount
-export default function useAxiosGet(fetchUrl: string): { response: any; error: string; validating: boolean } {
+/**
+ * Custom hook for axios to fetch raw file content on component mount
+ * @param fetchUrl The URL pointing to the raw file content
+ * @param path The path of the file, used for determining whether path is protected
+ */
+export default function useFileContent(
+ fetchUrl: string,
+ path: string
+): { response: any; error: string; validating: boolean } {
const [response, setResponse] = useState('')
const [validating, setValidating] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
+ const hashedToken = getStoredToken(path)
+ const url = fetchUrl + (hashedToken ? `&odpt=${hashedToken}` : '')
+
axios
// Using 'blob' as response type to get the response as a raw file blob, which is later parsed as a string.
// Axios defaults response parsing to JSON, which causes issues when parsing JSON files.
- .get(fetchUrl, { responseType: 'blob' })
+ .get(url, { responseType: 'blob' })
.then(async res => setResponse(await res.data.text()))
.catch(e => setError(e.message))
- .finally(() => {
- setValidating(false)
- })
- }, [fetchUrl])
+ .finally(() => setValidating(false))
+ }, [fetchUrl, path])
return { response, error, validating }
}
diff --git a/utils/fetchWithSWR.ts b/utils/fetchWithSWR.ts
index cd76f8d..ee4cb74 100644
--- a/utils/fetchWithSWR.ts
+++ b/utils/fetchWithSWR.ts
@@ -40,10 +40,10 @@ export function useProtectedSWRInfinite(path: string = '') {
if (previousPageData && !previousPageData.folder) return null
// First page with no prevPageData
- if (pageIndex === 0) return [`/api?path=${path}`, hashedToken]
+ if (pageIndex === 0) return [`/api/?path=${path}`, hashedToken]
// Add nextPage token to API endpoint
- return [`/api?path=${path}&next=${previousPageData.next}`, hashedToken]
+ return [`/api/?path=${path}&next=${previousPageData.next}`, hashedToken]
}
// Disable auto-revalidate, these options are equivalent to useSWRImmutable
diff --git a/utils/oAuthHandler.ts b/utils/oAuthHandler.ts
index 37c6ab9..bb4835b 100644
--- a/utils/oAuthHandler.ts
+++ b/utils/oAuthHandler.ts
@@ -43,7 +43,7 @@ export function extractAuthCodeFromRedirected(url: string): string {
// New URL search parameter
const params = new URLSearchParams(url.split('?')[1])
- return params.get('code') || ''
+ return params.get('code') ?? ''
}
// After a successful authorisation, the code returned from the Microsoft OAuth 2.0 authorization URL