diff --git a/components/Auth.tsx b/components/Auth.tsx new file mode 100644 index 0000000..e3df505 --- /dev/null +++ b/components/Auth.tsx @@ -0,0 +1,39 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +import { FunctionComponent, useState } from 'react' +import Image from 'next/image' +import { useRouter } from 'next/router' + +import { matchProtectedRoute } from '../utils/tools' +import useLocalStorage from '../utils/useLocalStorage' + +const Auth: FunctionComponent<{ redirect: string }> = ({ redirect }) => { + const authTokenPath = matchProtectedRoute(redirect) + + const router = useRouter() + const [token, setToken] = useLocalStorage(authTokenPath, '') + + return ( +
+
Enter Password
+ { + setToken(e.target.value) + }} + /> + +
+ ) +} + +export default Auth diff --git a/components/FileListing.tsx b/components/FileListing.tsx index bf413a5..8c0c2f3 100644 --- a/components/FileListing.tsx +++ b/components/FileListing.tsx @@ -17,6 +17,7 @@ import { VideoPreview } from './previews/VideoPreview' import { AudioPreview } from './previews/AudioPreview' import Loading from './Loading' import FourOhFour from './FourOhFour' +import Auth from './Auth' import TextPreview from './previews/TextPreview' import MarkdownPreview from './previews/MarkdownPreview' import CodePreview from './previews/CodePreview' @@ -105,12 +106,12 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) = const path = queryToPath(query) - const { data, error } = useStaleSWR(`/api?path=${path}`) + const { data, error } = useStaleSWR(`/api?path=${path}`, path) if (error) { return (
- + {error.message.includes('401') ? : }
) } diff --git a/config/site.json b/config/site.json index a6b64a9..3d06b69 100644 --- a/config/site.json +++ b/config/site.json @@ -1,4 +1,8 @@ { "title": "Spencer's OneDrive Index", - "footer": "Powered by onedrive-vercel-index. Made with ❤ by SpencerWoo." + "footer": "Powered by onedrive-vercel-index. Made with ❤ by SpencerWoo.", + "protectedRoutes": [ + "/🌞 Private folder/u-need-a-password", + "/🌞 Private folder/this-is-public" + ] } diff --git a/pages/api/index.ts b/pages/api/index.ts index 9c96111..56f4634 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { posix as pathPosix } from 'path' import apiConfig from '../../config/api.json' +import siteConfig from '../../config/site.json' const basePath = pathPosix.resolve('/', apiConfig.base) const encodePath = (path: string) => { @@ -49,6 +50,44 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) 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']) + if (req.headers['od-protected-token'] !== odProtectedToken.data) { + res.status(401).json({ error: 'Password required for this folder.' }) + return + } + } catch (error) { + // 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 + } + } + + // Handle response from OneDrive API const requestUrl = `${apiConfig.driveApi}/root${encodePath(path)}` const { data } = await axios.get(requestUrl, { headers: { Authorization: `Bearer ${accessToken}` }, diff --git a/utils/tools.ts b/utils/tools.ts index 47fb184..53118ae 100644 --- a/utils/tools.ts +++ b/utils/tools.ts @@ -1,5 +1,7 @@ -import useSWR, { cache, Key } from 'swr' import axios from 'axios' +import useSWR, { cache, Key } from 'swr' + +import siteConfig from '../config/site.json' /** * Extract the current web page's base url @@ -13,19 +15,46 @@ export const getBaseUrl = () => { } // Common axios fetch function -const fetcher = (url: string) => axios.get(url).then(res => res.data) +const fetcher = (url: string, token: string) => + axios + .get(url, { + headers: { 'od-protected-token': token }, + }) + .then(res => res.data) /** * Use stale SWR instead of revalidating on each request. Not ideal for this scenario but have to do * if fetching serverside props from component instead of pages. - * @param dataKey request url + * @param url request url * @returns useSWR instance */ -export const useStaleSWR = (dataKey: Key) => { +export const useStaleSWR = (url: Key, path: string) => { const revalidationOptions = { - revalidateOnMount: !cache.has(dataKey), + revalidateOnMount: !cache.has(url), revalidateOnFocus: false, revalidateOnReconnect: false, } - return useSWR(dataKey, fetcher, revalidationOptions) + const token = + typeof window !== 'undefined' ? JSON.parse(localStorage.getItem(matchProtectedRoute(path)) as string) : '' + + return useSWR([url, token], fetcher, revalidationOptions) +} + +export const matchProtectedRoute = (route: string) => { + const protectedRoutes = siteConfig.protectedRoutes + let authTokenPath = '' + for (const r of protectedRoutes) { + if ( + route.startsWith( + r + .split('/') + .map(p => encodeURIComponent(p)) + .join('/') + ) + ) { + authTokenPath = r + break + } + } + return authTokenPath } diff --git a/utils/useLocalStorage.ts b/utils/useLocalStorage.ts new file mode 100644 index 0000000..b137b2f --- /dev/null +++ b/utils/useLocalStorage.ts @@ -0,0 +1,78 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react' + +type SetValue = Dispatch> + +function useLocalStorage(key: string, initialValue: T): [T, SetValue] { + // Get from local storage then + // parse stored json or return initialValue + const readValue = (): T => { + // Prevent build error "window is undefined" but keep keep working + if (typeof window === 'undefined') { + return initialValue + } + + try { + const item = window.localStorage.getItem(key) + return item ? (JSON.parse(item) as T) : initialValue + } catch (error) { + console.warn(`Error reading localStorage key “${key}”:`, error) + return initialValue + } + } + + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(readValue) + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: SetValue = value => { + // Prevent build error "window is undefined" but keeps working + if (typeof window == 'undefined') { + console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`) + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = value instanceof Function ? value(storedValue) : value + + // Save to local storage + window.localStorage.setItem(key, JSON.stringify(newValue)) + + // Save state + setStoredValue(newValue) + + // We dispatch a custom event so every useLocalStorage hook are notified + window.dispatchEvent(new Event('local-storage')) + } catch (error) { + console.warn(`Error setting localStorage key “${key}”:`, error) + } + } + + useEffect(() => { + setStoredValue(readValue()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + const handleStorageChange = () => { + setStoredValue(readValue()) + } + + // this only works for other documents, not the current one + window.addEventListener('storage', handleStorageChange) + + // this is a custom event, triggered in writeValueToLocalStorage + window.addEventListener('local-storage', handleStorageChange) + + return () => { + window.removeEventListener('storage', handleStorageChange) + window.removeEventListener('local-storage', handleStorageChange) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return [storedValue, setValue] +} + +export default useLocalStorage