diff --git a/.env.example b/.env.example index 8dfdad5..5bea85b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file" -NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA" -NEXT_PUBLIC_SITE_PASSWORD="mbaharip" -NEXT_PUBLIC_VERCEL_URL="http://localhost:3000" \ No newline at end of file +NEXT_PUBLIC_ENCRYPTION_KEY= +NEXT_PUBLIC_SITE_PASSWORD= + +# Only fill with the domain name, without the protocol and trailing slash +# This will be used as a fallback if VERCEL_URL is not available +# Example: https://drive-demo.mbaharip.com -> drive.mbaharip.com +NEXT_PUBLIC_DOMAIN= diff --git a/.eslintrc.json b/.eslintrc.json index f0f3abe..e844b5b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,5 +2,6 @@ "extends": "next/core-web-vitals", "rules": { "@next/next/no-img-element": "off" - } + }, + "ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"] } diff --git a/.gitignore b/.gitignore index 4f49267..e30d6e4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,11 @@ next-env.d.ts # personal docs /docs /src/pages/api/legacy/ - /**/*/_legacy/ - +/data /.*/ -/src/_app \ No newline at end of file +/src/_app +/src/_page +/src/utils/_legacy +/src/components/_legacy +.env.production diff --git a/.prettierrc.js b/.prettierrc.js index 99bcabb..1f50f3e 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,10 +1,10 @@ "use strict"; module.exports = { plugins: [ - require("prettier-plugin-tailwindcss"), - require("@trivago/prettier-plugin-sort-imports"), + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss", ], - printWidth: 120, + printWidth: 80, tabWidth: 2, useTabs: false, semi: true, @@ -20,12 +20,17 @@ module.exports = { endOfLine: "lf", embeddedLanguageFormatting: "auto", singleAttributePerLine: true, + + tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"], + tailwindFunctions: ["twsx", "cn"], + tailwindConfig: "./tailwind.config.ts", + importOrder: [ "", - "^components/(.*)$", - "^(utils|hooks|context)/(.*)$", - "^types/(.*)$", - "^(config|styles)/(.*)$", + "^~/(app|components)/(.*)$", + "^~/(utils|hooks|context)/(.*)$", + "^~/types/(.*)$", + "^~/(config|styles)/(.*)$", "^[./]", ], importOrderSeparation: true, diff --git a/README.md b/README.md index 08b2e18..016e695 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,65 @@ FAQ

+## Version 2 is here! + +It seems there are some bugs on mobile devices that prevent the files list from rendering properly. +So I will use this chance to upgrade the project to `Next.js 14`, use `shadcn/ui`, and also add some new features. + +There also some changes on the configuration file, so make sure to read the [Migration Guide](#migration-guide) if you're upgrading from version 1.x. + +Here's what changed in version 2: + +### New Features + +- **Theme Select**, light mode? dark mode? you choose. +- **[shadcn/ui](https://ui.shadcn.com/)**, change the UI library to `shadcn/ui`. +- **Faster Response Time**, added cache to improve the response time. +- **Revalidate Data**, data fetching now utilizing revalidate. (can be changed from config file) + > You can manually revalidate the data by sending POST request to `/api/revalidate` with `Authorization` header set to `{{ process.env.SITE_PASSWORD }}`. + +### Changed + +- **General Layout**, New fresh look with `shadcn/ui`. +- **Download Link**, Added token that will be expired after x hour(s). (can be changed from config file) +- **Raw Link**, Raw link only available for image, video, and audio files. Raw link are recommended for hosting web project assets or comments on forum. +- **Footer**, now you can customize the footer from config file, also added support for Markdown. +- **Data Fetching**, now using `Server Actions` instead of `API Routes`. +- **Encryption**, moved to `Server Actions`. +- **Typescript Config**, target changed from `ES5` to `ES2021`. +- **Environment Variable**, now focus on Server Side Environment Variable. + - `NEXT_PUBLIC_ENCRYPTION_KEY` changed to `ENCRYPTION_KEY`. + - `NEXT_PUBLIC_SITE_PASSWORD` changed to `SITE_PASSWORD`. + - `MASTER_KEY` added. +- **Configuration File**, things are changed: + - `version` bumped to `2.0`. + - `masterKey` moved to `Environment Variable`. + - `apiConfig.revalidate` added. + - `siteConfig.siteNameTemplate` added. + - `siteConfig.siteAuthor` added. + - `siteConfig.robots` added. + - `siteConfig.footer` added. + - `siteConfig.defaultAccentColor` deprecated. + - `siteConfig.breadcrumbMax` added. + - `siteConfig.toaster` added. + - `siteConfig.navbarItems[number].icons` changed to `lucide icons`. + - `siteConfig.supports` added. + +### Fixed + +- Rendering issue on mobile devices. + +### Dependency Update + +- `@iconify/react` switched to `lucide-react`. +- `@mdx-js/loader` removed. +- `@mdx-js/react` removed. +- `@next/mdx` removed. +- `@popperjs/core` removed. +- `next` updated to `^14.1.4`. +- `next-seo` removed. +- `tailwind-merge` updated to `^2.2.2`. + ## What is this? `next-gdrive-index` is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive. @@ -44,32 +103,50 @@ I know there are a lot of people selling cheap edu account for Google Drive and ## File Security -All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission. -This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission. +> This only apply if `maxFileSize` is enabled. -But, every files `id` and `webContentLink` are encrypted with `AES-256-CBC` using your own key, so no one can access your files without the key. -Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link. +You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share. + +**Why?** because the download link will be redirected to the Google Drive download link, and it can only be accessed if the file is shared with `Anyone with the link can view` permission. + +If not, the download link will be redirected to the Google Drive web content link, and it can be accessed without sharing the file. + +But this might expose the file ID, so people might be able to access the files directly from Google Drive. (But I'm sure they can't access the folder itself, only the file that are being downloaded) ## Known Issue ### File size limit -File size limit causing some files can't be previewed, and the download will be redirected to the raw file link. +> This only apply if you're using platform that have file size limit, like Vercel. +> If you're using VPS or other platform that doesn't have file size limit, you can disable `maxFileSize` inside config file. + +File size limit causing some files can't be previewed, and the download will be redirected to the raw file link. +**This won't be fixed**, because it's a limitation from the deployment platform itself. ### Long Response Time -The flow of the project are: +From version 2.0, We are using Cache to improve the response time. +It might take a long time for deep nested folders, but once the data is fetched, it will be cached and the response time will be faster. -- Validate the path is valid or not. (Only applied for `/...[path]`) -- Fetch the password, readme, and files from Google Drive. -- Show to the user. +I also add [revalidate](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#revalidating-data) config on page file to 5 minutes. You can change the value inside `src/app/page.tsx` & `src/app/[...rest]/page.tsx`. -So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files. -To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster. +~~The flow of the project are:~~ -ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive. +- ~~Validate the path is valid or not. (Only applied for `/...[path]`)~~ +- ~~Fetch the password, readme, and files from Google Drive.~~ +- ~~Show to the user.~~ -### Shared Drive is now supported +~~So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files. +To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.~~ + +~~ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.~~ + +### Can't seek on audio and video preview + +It looks like the audio and video preview can't seek, so you need to listen/watch from the beginning. +I'm still looking for a solution for this. + +### ~~Shared Drive is now supported~~ ~~I don't have Google Shared Drive, so I can't test it and implement it.~~ Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](https://github.com/mbahArip/next-gdrive-index/pull/4) @@ -78,3 +155,47 @@ Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](http For now, I don't have any plan to implement this, because I think it's not necessary. All Google Drive files like Docs, Sheets, and Slides are hidden from the list. + +**PR are welcome if you want to implement this.** + +## Migration Guide + +If you're upgrading from version 1.x to version 2, there are some changes that you need to do: + +### Configuration File + +> You can check the new Configuration Schema on `/src/schema.ts` + +There are some changes on the configuration file, here's the list of changes: + +- `masterKey` **REMOVED**, we don't need this anymore + +- `apiConfig.rootFolder` **CHANGED**, you need to encrypt the folder ID first + `https://drive-demo.mbaharip.com/api/internal/encrypt?q={{ folderId }}` + +- `apiConfig.proxyThumbnail` **ADDED**, default value is `true` +- `siteConfig.siteNameTemplate` **ADDED**, default value is `%s - next-gdrive-index` +- `siteConfig.siteAuthor` **ADDED**, default value is `mbahArip` +- `siteConfig.robots` **ADDED**, default value is `noindex, nofollow` +- `siteConfig.footer` **ADDED**, default value is `Powered by next-gdrive-index` +- `siteConfig.defaultAccentColor` **REMOVED**, you can set the theme from `/src/app/globals.css` (more information about [shadcn/ui theme](https://ui.shadcn.com/docs/theming)) +- `siteConfig.breadcrumbMax` **ADDED**, default value is `3` +- `siteConfig.toaster` **ADDED**, default value is `{ position: "bottom-right', duration: 3000 }` +- `siteConfig.navbarItems[number].icons` **CHANGED**, you need to use [Lucide Icons](https://lucide.dev/icons) now +- `siteConfig.supports` **ADDED**, you can leave it empty if you don't want to use it + +### Environment Variable + +Now all the environment variable are on the server side, so you need to change the environment variable: + +- `NEXT_PUBLIC_ENCRYPTION_KEY` **CHANGED** to `ENCRYPTION_KEY` +- `NEXT_PUBLIC_SITE_PASSWORD` **CHANGED** to `SITE_PASSWORD` +- `NEXT_PUBLIC_VERCEL_URL` **CHANGED** to `NEXT_PUBLIC_DOMAIN` + +## Sponsors and Donations + +If you think I deserve it, you can support me by: + +- [Paypal (USD)](https://paypal.me/mbaharip) +- [Ko-fi (USD)](https://ko-fi.com/mbaharip) +- [Saweria (IDR)](https://saweria.co/mbaharip) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3f02df2..0000000 --- a/TODO.md +++ /dev/null @@ -1,41 +0,0 @@ -- Redo how to handle error - -# / -- ~~Override opengraph using banner image~~ -- ~~Render readme file~~ -- ~~Copy folder url~~ -- ~~Copy file direct url~~ -- ~~Download file url~~ -- ~~Index password~~ - -# /:path -- ~~Override opengraph using banner image / thumbnail image~~ -- ~~Render readme file~~ -- ~~Copy folder url~~ -- ~~Copy file direct url~~ -- ~~Download file url~~ -- ~~Password protected~~ - - Issues: Return 500 error if it's wrong password -- File Preview - - 3D > 3DPreview - - Audio > AudioPreview - - Archive > UnknownPreview - - RichText > MarkdownPreview - - OfficeWord > OfficePreview - - OfficeExcel > OfficePreview - - OfficePowerPoint > OfficePreview - - PDF > PDFPreview - - Database > UnknownPreview - - Image > ImagePreview - - Code > CodePreview - - Text > TextPreview - - Video > VideoPreview - - Font > UnknownPreview - - Default > UnknownPreview - - Binary > UnknownPreview - -# /download/:path -- ~~Download file based on path~~ -- ~~Validate path~~ -- Protected file -- Token based for protected file \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..e048066 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "~/components", + "utils": "~/utils" + } +} diff --git a/next.config.js b/next.config.js index 8851780..f0754a2 100644 --- a/next.config.js +++ b/next.config.js @@ -1,34 +1,23 @@ -/** @type {import('next').NextConfig} */ - -const withMDX = require("@next/mdx")({ - extension: /\.mdx?$/, - options: { - // If you use remark-gfm, you'll need to use next.config.mjs - // as the package is ESM only - // https://github.com/remarkjs/remark-gfm#install - remarkPlugins: [], - rehypePlugins: [], - // If you use `MDXProvider`, uncomment the following line. - providerImportSource: "@mdx-js/react", - }, -}); - const nextConfig = { - pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], reactStrictMode: true, - async headers() { - return [ - { - source: "/:path*", - headers: [ - { - key: "Cache-Control", - value: "max-age=300, s-maxage=300, stale-while-revalidate, public", - }, - ], - }, - ]; + webpack: (config) => { + config.resolve.alias.canvas = false; + + return config; }, + // async headers() { + // return [ + // { + // source: "/:path*", + // headers: [ + // { + // key: "Cache-Control", + // value: "max-age=300, s-maxage=300, stale-while-revalidate, public", + // }, + // ], + // }, + // ]; + // }, }; -module.exports = withMDX(nextConfig); +module.exports = nextConfig; diff --git a/package.json b/package.json index 5e77880..f507f8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "drive-manager", - "version": "0.1.0", + "name": "next-gdrive-index", + "version": "2.0.0", "private": true, "scripts": { "dev": "next dev -p 3000", @@ -12,24 +12,59 @@ }, "dependencies": { "@cyntler/react-doc-viewer": "^1.13.0", - "@iconify/react": "^4.1.1", - "@mdx-js/loader": "^2.3.0", - "@mdx-js/react": "^2.3.0", - "@next/mdx": "^13.4.19", - "@popperjs/core": "^2.11.8", + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", "axios": "^1.3.5", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.0.1", "googleapis": "^118.0.0", + "input-otp": "^1.2.3", "jsonwebtoken": "^9.0.0", "jszip": "^3.10.1", - "next": "^13.4.13", - "next-seo": "^6.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-h5-audio-player": "^3.8.6", + "lucide-react": "^0.363.0", + "next": "^14.1.4", + "next-themes": "^0.3.0", + "nextjs-toploader": "^1.6.11", + "react": "^18", + "react-day-picker": "^8.10.0", + "react-dom": "^18", + "react-h5-audio-player": "^3.9.1", + "react-hook-form": "^7.51.2", + "react-hot-toast": "^2.4.1", "react-is": "^18.2.0", "react-markdown": "^8.0.7", "react-player": "^2.13.0", "react-popper": "^2.3.0", + "react-resizable-panels": "^2.0.16", "react-toastify": "^9.1.3", "rehype-katex": "^6.0.3", "rehype-prism-plus": "^1.6.3", @@ -37,7 +72,12 @@ "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", "remark-slug": "^7.0.1", - "remark-toc": "^8.0.1" + "remark-toc": "^8.0.1", + "sonner": "^1.4.41", + "tailwind-merge": "^2.2.2", + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.0", + "zod": "^3.22.4" }, "devDependencies": { "@faker-js/faker": "^8.0.2", @@ -46,19 +86,18 @@ "@types/cors": "^2.8.13", "@types/jsonwebtoken": "^9.0.1", "@types/mime-types": "^2.1.1", - "@types/node": "18.15.11", - "@types/react": "18.0.35", - "@types/react-dom": "18.0.11", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", "@types/three": "^0.150.2", - "autoprefixer": "10.4.14", + "autoprefixer": "^10.4.19", "encoding": "^0.1.13", "eslint": "8.38.0", - "eslint-config-next": "^13.4.3", - "postcss": "8.4.22", - "prettier": "^2.8.7", - "prettier-plugin-tailwindcss": "^0.2.7", - "tailwind-merge": "^1.14.0", - "tailwindcss": "3.3.1", - "typescript": "5.0.4" + "eslint-config-next": "^14.1.4", + "postcss": "^8.4.38", + "prettier": "3.0.0", + "prettier-plugin-tailwindcss": "0.5.12", + "tailwindcss": "^3.4.1", + "typescript": "^5" } } diff --git a/postcss.config.js b/postcss.config.js index 33ad091..12a703d 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/src/app/@explorer.tsx b/src/app/@explorer.tsx new file mode 100644 index 0000000..9acdbc8 --- /dev/null +++ b/src/app/@explorer.tsx @@ -0,0 +1,189 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useContext, useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { Separator } from "~/components/ui/separator"; + +import { LayoutContext } from "~/context/layoutContext"; + +import config from "~/config/gIndex.config"; + +import FileGrid from "./@file.grid"; +import FileList from "./@file.list"; +import { GetFiles } from "./actions"; + +type Props = { + files: z.infer[]; + nextPageToken?: string; + root?: boolean; +}; +export default function FileBrowser({ files, nextPageToken, root }: Props) { + const { layout } = useContext(LayoutContext); + const pathname = usePathname(); + const prevPath = useMemo(() => { + const path = pathname + .split("/") + .slice(0, -1) + .join("/") + .replace(/\/+/g, "/"); + + return new URL(path, config.basePath).pathname; + }, [pathname]); + + const [fileList, setFileList] = + useState[]>(files); + const [nextToken, setNextToken] = useState(nextPageToken); + const [loadMoreLoading, setLoadMoreLoading] = useState(false); + + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(false); + }, []); + + const onLoadMore = async () => { + setLoadMoreLoading(true); + try { + if (!nextToken) throw new Error("No more files to load"); + const data = await GetFiles({ pageToken: nextToken }); + const uniqueData = [...fileList, ...data.files].filter( + (item, index, array) => + index === array.findIndex((i) => i.encryptedId === item.encryptedId), + ); + setFileList(uniqueData); + // const uniqueData = new Set([...fileList, ...data.files]); + // setFileList([...uniqueData]); + setNextToken(data.nextPageToken); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message); + } finally { + setLoadMoreLoading(false); + } + }; + + if (loading) { + return ( +
+ +

Wait a moment while we load your files...

+
+ ); + } + + return ( +
+ {!root && ( + + )} + {!fileList.length && ( +
+ + + There are no files in this folder + +
+ )} + {layout === "list" && ( +
+ {fileList.map((file) => ( +
+ + +
+ ))} +
+ )} + {layout === "grid" && ( +
+ {fileList.map((file) => ( +
+ +
+ ))} +
+ )} + + {nextToken && ( + + )} +
+ ); +} diff --git a/src/app/@file.grid.tsx b/src/app/@file.grid.tsx new file mode 100644 index 0000000..f86b9ca --- /dev/null +++ b/src/app/@file.grid.tsx @@ -0,0 +1,295 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; + +import useMediaQuery from "~/hooks/useMediaQuery"; +import bytesToReadable from "~/utils/bytesFormat"; +import { durationToReadable } from "~/utils/durationFormat"; +import { getPreviewIcon } from "~/utils/previewHelper"; + +import config from "~/config/gIndex.config"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + data: z.infer; +}; +export default function FileGrid({ data }: Props) { + const pathname = usePathname(); + const filePath = useMemo(() => { + // const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`; + // Set to pathname to remove the /e prefix + const path = [pathname, encodeURIComponent(data.name)] + .join("/") + .replace(/\/+/g, "/"); + + return new URL(path, config.basePath).pathname; + }, [data, pathname]); + + const [actionOpen, setActionOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const onCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + toast.promise( + navigator.clipboard.writeText( + new URL(filePath, config.basePath).toString(), + ), + { + loading: "Copying link...", + success: "Link copied!", + error: "Failed to copy link", + }, + ); + } catch (error) { + const e = error as Error; + console.error(e.message); + } + }; + const onDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + toast.loading("Creating download token...", { + id: `download-${data.encryptedId}`, + }); + try { + const token = await CreateDownloadToken(); + if (!token) throw new Error("Failed to create download token"); + toast.success("Opening download link...", { + id: `download-${data.encryptedId}`, + }); + + const timeout = setTimeout(() => { + clearTimeout(timeout); + window.open(`/api/download/${data.encryptedId}?token=${token}`); + }, 1000); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message, { + id: `download-${data.encryptedId}`, + }); + } + }; + + return ( +
+ {isDesktop ? ( + + + + + + + + Copy link + + {data.mimeType.includes("folder") ? null : ( + + + Download + + )} + + + ) : ( + + + + + + + Actions + + What would you like to do with this file? + + +
+ + + + {data.mimeType.includes("folder") ? null : ( + + + + )} +
+ + + + + + +
+
+ )} + +
+ {/* If it's media, show thumbnail */} +
+ {data.thumbnailLink && + (data.mimeType.startsWith("video") || + data.mimeType.startsWith("image")) ? ( + <> + {data.name} + {data.name} + + {data.mimeType.startsWith("video") && ( + <> + +
+ {durationToReadable( + data.videoMediaMetadata?.durationMillis || 0, + )} +
+ + )} + + ) : ( + + )} +
+ + {/* File data */} +
+ + {data.fileExtension + ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") + : data.name} + +
+ + {data.mimeType.includes("folder") + ? "folder" + : data.fileExtension} + + {!data.mimeType.includes("folder") && ( + <> + + + {bytesToReadable(data.size || 0)} + + + )} +
+
+ + {new Date(data.modifiedTime).toLocaleDateString()} + +
+
+
+ +
+ ); +} diff --git a/src/app/@file.list.tsx b/src/app/@file.list.tsx new file mode 100644 index 0000000..e61a5ac --- /dev/null +++ b/src/app/@file.list.tsx @@ -0,0 +1,283 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; + +import useMediaQuery from "~/hooks/useMediaQuery"; +import bytesToReadable from "~/utils/bytesFormat"; +import { getPreviewIcon } from "~/utils/previewHelper"; + +import config from "~/config/gIndex.config"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + data: z.infer; +}; +export default function FileList({ data }: Props) { + const pathname = usePathname(); + + const filePath = useMemo(() => { + // const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`; + // Set to pathname to remove the /e prefix + const path = [pathname, encodeURIComponent(data.name)] + .join("/") + .replace(/\/+/g, "/"); + + return new URL(path, config.basePath).pathname; + }, [data, pathname]); + const [actionOpen, setActionOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const onCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + toast.promise( + navigator.clipboard.writeText( + new URL(filePath, config.basePath).toString(), + ), + { + loading: "Copying link...", + success: "Link copied!", + error: "Failed to copy link", + }, + ); + } catch (error) { + const e = error as Error; + console.error(e.message); + } + }; + const onDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + toast.loading("Creating download token...", { + id: `download-${data.encryptedId}`, + }); + try { + const token = await CreateDownloadToken(); + if (!token) throw new Error("Failed to create download token"); + toast.success("Opening download link...", { + id: `download-${data.encryptedId}`, + }); + + const timeout = setTimeout(() => { + clearTimeout(timeout); + window.open(`/api/download/${data.encryptedId}?token=${token}`); + }, 1000); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message, { + id: `download-${data.encryptedId}`, + }); + } + }; + + return ( +
+
+ {isDesktop ? ( + + + + + + + + Copy link + + {data.mimeType.includes("folder") ? null : ( + + + Download + + )} + + + ) : ( + + + + + + + Actions + + What would you like to do with this file? + + +
+ + + + {data.mimeType.includes("folder") ? null : ( + + + + )} +
+ + + + + + +
+
+ )} +
+ +
+ {/* If it's media, show thumbnail */} +
+ {data.thumbnailLink && + (data.mimeType.startsWith("video") || + data.mimeType.startsWith("image")) ? ( + <> + {data.name} + + {data.mimeType.startsWith("video") && ( + <> + + + )} + + ) : ( + + )} +
+ + {/* File data */} +
+ + {data.fileExtension + ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") + : data.name} + +
+ + {data.mimeType.includes("folder") + ? "folder" + : data.fileExtension} + + {!data.mimeType.includes("folder") && ( + <> + + + {bytesToReadable(data.size || 0)} + + + )} +
+
+ + {new Date(data.modifiedTime).toLocaleDateString()} + +
+
+
+ +
+ ); +} diff --git a/src/app/@footer.tsx b/src/app/@footer.tsx new file mode 100644 index 0000000..5830490 --- /dev/null +++ b/src/app/@footer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import ReactMarkdown from "react-markdown"; + +type Props = { + content: string; +}; +export default function Footer({ content }: Props) { + return ( +
+ ( +

+ {children} +

+ ), + a: ({ node, children, ...props }) => { + const isExternal = props.href?.startsWith("http"); + + return ( + + {children} + + ); + }, + }} + > + {content} +
+
+ ); +} diff --git a/src/app/@header.breadcrumb.tsx b/src/app/@header.breadcrumb.tsx new file mode 100644 index 0000000..d371315 --- /dev/null +++ b/src/app/@header.breadcrumb.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import Link from "next/link"; +import { Fragment, useState } from "react"; +import { z } from "zod"; +import { Schema_Breadcrumb } from "~/schema"; + +import Icon from "~/components/Icon"; +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb"; +import { Button } from "~/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, +} from "~/components/ui/dropdown-menu"; +import { Separator } from "~/components/ui/separator"; +import { Skeleton } from "~/components/ui/skeleton"; + +import useMediaQuery from "~/hooks/useMediaQuery"; + +import config from "~/config/gIndex.config"; + +type Props = { + data: z.infer[]; + loading?: boolean; +}; +export default function HeaderBreadcrumb({ data, loading }: Props) { + const [open, setOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + if (loading) return ; + + return ( +
+ + + + + +
+ + ~ +
+ +
+
+ {!!data.length ? ( + <> + + + {data.length > config.siteConfig.breadcrumbMax ? ( + <> + {isDesktop ? ( + + + + + + {data + .slice(0, -config.siteConfig.breadcrumbMax + 1) + .map((item, _, array) => ( + + + item.href) + .join("/")}`} + className='w-full' + > + {item.label} + + + + ))} + + + ) : ( + + + + + + + + Navigate to parent directories + + + +
+ {data + .slice(0, -config.siteConfig.breadcrumbMax + 1) + .map((item, _, array) => ( + + + item.href) + .join("/")}`} + className='w-full py-1.5' + > + {item.label} + + + + ))} +
+ + + + + + +
+
+ )} + + + + ) : null} + + {data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => ( + + + {item.href ? ( + <> + + item.href) + .join("/") + .replace(/\/\//g, "/")}`} + > + {item.label} + + + + ) : ( + + {item.label} + + )} + + {item.href && } + + ))} + + ) : null} +
+
+
+ ); +} diff --git a/src/app/@header.button.tsx b/src/app/@header.button.tsx new file mode 100644 index 0000000..c8e99f1 --- /dev/null +++ b/src/app/@header.button.tsx @@ -0,0 +1,604 @@ +"use client"; + +import { PropsWithChildren, useContext, useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { Input } from "~/components/ui/input"; +import { Separator } from "~/components/ui/separator"; +import { Skeleton } from "~/components/ui/skeleton"; + +import { LayoutContext } from "~/context/layoutContext"; +import useMediaQuery from "~/hooks/useMediaQuery"; +import useRouter from "~/hooks/usePRouter"; +import bytesToReadable from "~/utils/bytesFormat"; +import { getPreviewIcon } from "~/utils/previewHelper"; + +import { RedirectSearchFile, SearchFile } from "./actions"; + +export default function HeaderButton({ children }: PropsWithChildren) { + const { layout, setLayout } = useContext(LayoutContext); + + const [layoutOpen, setLayoutOpen] = useState(false); + + const [loading, setLoading] = useState(true); + const [snap, setSnap] = useState(0.3); + const [searchOpen, setSearchOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearchInput, setDebouncedSearchInput] = useState(""); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(""); + const [searchResults, setSearchResults] = useState< + z.infer[] + >([]); + const [nextPageToken, setNextPageToken] = useState(); + + const isDesktop = useMediaQuery("(min-width: 768px)"); + + useEffect(() => { + setLoading(false); + }, []); + + useEffect(() => { + if (!searchInput) { + setSearchLoading(true); + setDebouncedSearchInput(""); + setSearchError(""); + setSearchResults([]); + return; + } + const handler = setTimeout(() => { + setDebouncedSearchInput(searchInput); + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [searchInput]); + useEffect(() => { + if (!debouncedSearchInput) return; + onSearch(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchInput]); + + const onSearch = async () => { + setSearchLoading(true); + try { + const data = await SearchFile(debouncedSearchInput, nextPageToken); + setSearchResults(data.files); + setNextPageToken(data.nextPageToken); + } catch (error) { + const e = error as Error; + console.error(e); + setSearchError(e.message); + } finally { + setSearchLoading(false); + } + }; + + if (loading) { + return ( +
+ {" "} + +
+ ); + } + + return ( +
+ {isDesktop ? ( + + + + + + setLayout("grid")} + > + + Grid + + setLayout("list")} + > + + List + + + + ) : ( + + + + + + + Layout + + Choose a layout for the files explorer + + +
+ + + + + + +
+ + + + + +
+
+ )} + {isDesktop ? ( + { + if (!open) { + setSearchInput(""); + setSnap(0.3); + } + setSearchOpen(open); + }} + > + + + + + + Search + + Search for files in your drive + + +
+
+ { + setSearchError(""); + if (!e.target.value) { + setSearchInput(""); + setSnap(0.3); + } else { + setSearchLoading(true); + setSearchInput(e.target.value); + setSnap(1); + } + }} + /> + + + +
+ +
+ {!debouncedSearchInput ? ( +
+ + + Start typing to search + +
+ ) : searchLoading ? ( +
+ + + Searching... + +
+ ) : searchError ? ( +
+ + + {searchError} + +
+ ) : !searchResults.length ? ( +
+ + + We couldn't find any results + +
+ ) : ( +
+ {searchResults.map((result) => ( + + ))} +
+ )} +
+
+
+
+ ) : ( + { + if (!open) { + setSearchInput(""); + setSnap(0.3); + } + setSearchOpen(open); + }} + shouldScaleBackground + fadeFromIndex={0} + snapPoints={[0.3, 1]} + activeSnapPoint={snap} + setActiveSnapPoint={setSnap} + > + + + + + + Search + + Search for files in your drive + + +
+
+ { + setSearchError(""); + if (!e.target.value) { + setSearchInput(""); + setSnap(0.3); + } else { + setSearchLoading(true); + setSearchInput(e.target.value); + setSnap(1); + } + }} + /> + + + +
+ +
+ {!debouncedSearchInput ? ( +
+ + + Start typing to search + +
+ ) : searchLoading ? ( +
+ + + Searching... + +
+ ) : searchError ? ( +
+ + + {searchError} + +
+ ) : !searchResults.length ? ( +
+ + + We couldn't find any results + +
+ ) : ( +
+ {searchResults.map((result) => ( + + ))} +
+ )} +
+
+
+
+ )} +
+ ); +} + +function SearchResultItem({ data }: { data: z.infer }) { + const router = useRouter(); + return ( +
{ + toast.loading("Getting file path...", { + id: `open-${data.encryptedId}`, + }); + try { + const paths = await RedirectSearchFile(data.encryptedId); + router.push(`/${paths}`); + toast.success("Redirecting...", { + id: `open-${data.encryptedId}`, + }); + } catch (error) { + const e = error as Error; + console.error(e); + toast.error(e.message, { + id: `open-${data.encryptedId}`, + }); + } + }} + > +
+ {/* If it's media, show thumbnail */} +
+ {data.thumbnailLink && + (data.mimeType.startsWith("video") || + data.mimeType.startsWith("image")) ? ( + <> + {data.name} + + {data.mimeType.startsWith("video") && ( + <> + + + )} + + ) : ( + + )} +
+ + {/* File data */} +
+ + {data.fileExtension + ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") + : data.name} + +
+ + {data.mimeType.includes("folder") ? "folder" : data.fileExtension} + + {!data.mimeType.includes("folder") && ( + <> + + + {bytesToReadable(data.size || 0)} + + + )} +
+
+ + {new Date(data.modifiedTime).toLocaleDateString()} + +
+
+
+
+ ); +} diff --git a/src/app/@header.title.tsx b/src/app/@header.title.tsx new file mode 100644 index 0000000..3a9da84 --- /dev/null +++ b/src/app/@header.title.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PropsWithChildren } from "react"; + +import { Skeleton } from "~/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; + +export default function HeaderTitle({ + children, + loading, +}: PropsWithChildren<{ loading?: boolean }>) { + if (loading) return ; + + return ( + + +

{children}

+
+ {children} +
+ ); +} diff --git a/src/app/@header.tsx b/src/app/@header.tsx new file mode 100644 index 0000000..9ef6071 --- /dev/null +++ b/src/app/@header.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { Schema_Breadcrumb } from "~/schema"; +import { cn } from "~/utils"; + +import HeaderBreadcrumb from "./@header.breadcrumb"; + +type Props = { + name: string; + breadcrumb?: z.infer[]; +}; +export default function Header({ name, breadcrumb }: Props) { + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(false); + }, []); + + return ( +
+
+ {/* {name} */} + + + {/* */} +
+
+ ); +} diff --git a/src/components/Markdown/index.tsx b/src/app/@markdown.tsx similarity index 55% rename from src/components/Markdown/index.tsx rename to src/app/@markdown.tsx index ff7ccd4..57c75c1 100644 --- a/src/components/Markdown/index.tsx +++ b/src/app/@markdown.tsx @@ -1,7 +1,8 @@ -import { jetbrainsMono } from "pages/_app"; +"use client"; + import { useRef, useState } from "react"; +import toast from "react-hot-toast"; import ReactMarkdown from "react-markdown"; -import { toast } from "react-toastify"; import rehypeKatex from "rehype-katex"; import rehypePrism from "rehype-prism-plus"; import rehypeRaw from "rehype-raw"; @@ -10,10 +11,50 @@ import remarkMath from "remark-math"; import remarkSlug from "remark-slug"; import remarkToc from "remark-toc"; -import Button from "components/Button"; +import Icon from "~/components/Icon"; -import "styles/highlight.css"; -import "styles/markdown.css"; +import "./highlight.css"; + +type Props = { + content: string; +}; +export default function Markdown({ content }: Props) { + return ( +
+ ( +

+ {children} +

+ ), + pre: PreComponent, + code: ({ node, inline, className, children, ...props }) => ( + + {children} + + ), + }} + > + {content} +
+
+ ); +} interface PreProps extends React.HTMLAttributes {} function PreComponent(props: PreProps) { @@ -25,7 +66,12 @@ function PreComponent(props: PreProps) { const handleCopy = () => { if (!codeRef.current) return; + if (isCopied || isError) return; if (copyTimeout) clearTimeout(copyTimeout); + const toastId = `copy-${Math.random() * 1000}`; + toast.loading("Copying code to clipboard...", { + id: toastId, + }); try { navigator.clipboard.writeText(codeRef.current.textContent as string); setIsError(false); @@ -35,7 +81,9 @@ function PreComponent(props: PreProps) { setIsCopied(false); }, 2000), ); - toast.success("Code copied to clipboard"); + toast.success("Code copied to clipboard", { + id: toastId, + }); } catch (error: any) { setIsCopied(false); setIsError(true); @@ -44,13 +92,15 @@ function PreComponent(props: PreProps) { setIsError(false); }, 2000), ); - toast.error("Failed to copy code to clipboard"); + toast.error("Failed to copy code to clipboard", { + id: toastId, + }); } }; return (
{ setShowCopy(true); }} @@ -60,54 +110,33 @@ function PreComponent(props: PreProps) { setIsError(false); }} > -
- + +
         {props.children}
       
); } - -interface MarkdownProps { - content: string; -} -export default function Markdown(props: MarkdownProps) { - return ( -
- ( - - {children} - - ), - }} - > - {props.content} - -
- ); -} diff --git a/src/app/@navbar.tsx b/src/app/@navbar.tsx new file mode 100644 index 0000000..0524176 --- /dev/null +++ b/src/app/@navbar.tsx @@ -0,0 +1,579 @@ +"use client"; + +import { TooltipTrigger } from "@radix-ui/react-tooltip"; +import { useTheme } from "next-themes"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { Separator } from "~/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetTrigger, +} from "~/components/ui/sheet"; +import { Tooltip, TooltipContent } from "~/components/ui/tooltip"; + +import useMediaQuery from "~/hooks/useMediaQuery"; + +import config from "~/config/gIndex.config"; + +import { ClearPassword } from "./actions"; + +export default function Navbar() { + const pathname = usePathname(); + const router = useRouter(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const { theme, themes, setTheme } = useTheme(); + + const [open, setOpen] = useState(false); + const [themeOpen, setThemeOpen] = useState(false); + + useEffect(() => { + if (isDesktop) setOpen(false); + }, [isDesktop]); + + async function onClearPassword() { + const promise = new Promise(async (resolve, reject) => { + const clear = await ClearPassword(); + if (!clear.success) reject(new Error(clear.message)); + resolve(clear.message); + }); + toast.promise(promise, { + loading: "Clearing all saved password...", + success: () => { + setOpen(false); + router.refresh(); + return "All saved password cleared successfully!"; + }, + error: (error) => error.message, + }); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/@not-found.tsx b/src/app/@not-found.tsx new file mode 100644 index 0000000..9b73830 --- /dev/null +++ b/src/app/@not-found.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; + +import useRouter from "~/hooks/usePRouter"; + +export default function NotFoundComponent() { + const router = useRouter(); + return ( +
+ + + The file you are looking for does not exist + + +
+ + +
+
+ ); +} diff --git a/src/app/@password.tsx b/src/app/@password.tsx new file mode 100644 index 0000000..7bc2cac --- /dev/null +++ b/src/app/@password.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; + +import { + CheckPassword, + CheckSitePassword, + SetPassword, + SetSitePassword, +} from "./actions"; + +type Props = { + path: string; + checkPaths?: { path: string; id: string }[]; + errorMessage?: string; +}; +export default function Password({ path, checkPaths, errorMessage }: Props) { + const router = useRouter(); + + const [input, setInput] = useState(""); + const [submitLoading, setSubmitLoading] = useState(false); + const [loading, setLoading] = useState(true); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitLoading(true); + toast.loading("Checking password...", { + id: "password", + }); + + try { + if (!input) throw new Error("Password is required"); + if (path === "global") { + const set = await SetSitePassword(input); + if (!set.success) throw new Error(set.message); + + const check = await CheckSitePassword(); + if (!check.success) throw new Error(check.message); + } else { + if (!checkPaths) + throw new Error("No path found, try to refresh the page."); + const set = await SetPassword(path, input); + if (!set.success) throw new Error(set.message); + + const check = await CheckPassword(checkPaths); + if (!check.success) throw new Error(check.message); + } + + toast.success("Password accepted! Refreshing...", { + id: "password", + }); + console.log("Password accepted! Refreshing..."); + router.refresh(); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message, { + id: "password", + }); + setSubmitLoading(false); + } finally { + setInput(""); + } + }; + + useEffect(() => { + setLoading(false); + }, []); + + if (loading) + return ( +
+ +

Checking password...

+
+ ); + + return ( +
+ Password illustration +
+

+ {path === "global" + ? "This site are password protected" + : "The folder or file you are trying to access is password protected"} +

+ + Please enter the password to access the content + +
+ +
+ setInput(e.target.value)} + /> + +
+ + {errorMessage || "Nothing wrong here, just a password wall"} + +
+ ); +} diff --git a/src/app/@preview.action.tsx b/src/app/@preview.action.tsx new file mode 100644 index 0000000..da4e462 --- /dev/null +++ b/src/app/@preview.action.tsx @@ -0,0 +1,333 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTrigger, +} from "~/components/ui/drawer"; +import { Separator } from "~/components/ui/separator"; + +import useMediaQuery from "~/hooks/useMediaQuery"; +import bytesToReadable from "~/utils/bytesFormat"; +import { durationToReadable } from "~/utils/durationFormat"; + +import config from "~/config/gIndex.config"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + file: z.infer; +}; +export default function PreviewAction({ file }: Props) { + const pathname = usePathname(); + const showRaw = useMemo(() => { + return ( + file.mimeType.startsWith("image") || + file.mimeType.startsWith("video") || + file.mimeType.startsWith("audio") + ); + }, [file]); + const fileInfo = useMemo<{ label: string; value: string }[]>(() => { + const value = [ + { + label: "File Name", + value: file.name, + }, + { + label: "Mime Type", + value: file.mimeType, + }, + { + label: "Size", + value: bytesToReadable(file.size || 0), + }, + { + label: "Last Modified", + value: file.modifiedTime, + }, + ]; + if (file.imageMediaMetadata) { + value.push({ + label: "Dimension", + value: `${file.imageMediaMetadata.width} x ${file.imageMediaMetadata.height}`, + }); + } + if (file.videoMediaMetadata) { + value.push({ + label: "Dimension", + value: `${file.videoMediaMetadata.width} x ${file.videoMediaMetadata.height}`, + }); + value.push({ + label: "Duration", + value: durationToReadable(file.videoMediaMetadata.durationMillis), + }); + } + + return value; + }, [file]); + + const [downloading, setDownloading] = useState(false); + const [diffOpen, setDiffOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const onCopyRaw = async () => { + try { + const rawURL = new URL( + `/api/raw/${pathname}`.replace(/\/+/g, "/"), + config.basePath, + ); + rawURL.searchParams.append("token", file.encryptedId); + toast.promise(navigator.clipboard.writeText(rawURL.toString()), { + loading: "Copying link...", + success: "Link copied!", + error: "Failed to copy link", + }); + } catch (error) { + const e = error as Error; + console.error(e.message); + } + }; + const onCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + toast.loading("Creating download token...", { + id: `download-${file.encryptedId}`, + }); + try { + const token = await CreateDownloadToken(); + if (!token) throw new Error("Failed to create download token"); + await navigator.clipboard.writeText( + new URL( + `/api/download/${file.encryptedId}?token=${token}`, + config.basePath, + ).toString(), + ); + toast.success("Link copied...", { + id: `download-${file.encryptedId}`, + }); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message, { + id: `download-${file.encryptedId}`, + }); + } + }; + const onDownload = async (e: React.MouseEvent) => { + e.stopPropagation(); + setDownloading(true); + toast.loading("Creating download token...", { + id: `download-${file.encryptedId}`, + }); + try { + const token = await CreateDownloadToken(); + if (!token) throw new Error("Failed to create download token"); + toast.success("Opening download link...", { + id: `download-${file.encryptedId}`, + }); + + const timeout = setTimeout(() => { + clearTimeout(timeout); + window.open(`/api/download/${file.encryptedId}?token=${token}`); + setDownloading(false); + }, 1000); + } catch (error) { + const e = error as Error; + console.error(e.message); + toast.error(e.message, { + id: `download-${file.encryptedId}`, + }); + setDownloading(false); + } + }; + + return ( +
+ +
+
+ {isDesktop ? ( + + + + Difference between download link and raw link? + + + +
+

Why?

+

+ Some services need the file extension to be present in the + URL to properly embed the file. +
+ - The download link only have encrypted file id. +
+ - The raw link will have the whole path includes the file + name and extension. +
+
+ So if you want to embed the file, it's recommended to + use the raw link instead of the download link. +

+

+ Note: This is only applicable for video, audio, and image + files. +

+

Ref

+

+ This information is based on the onedrive-vercel-index + project documentation. +
+ + Customise Direct Link - onedrive-vercel-index + +

+
+
+
+ ) : ( + + + + Difference between download link and raw link? + + + + +
+

Why?

+

+ Some services need the file extension to be present in the + URL to properly embed the file. +
+ - The download link only have encrypted file id. +
+ - The raw link will have the whole path includes the file + name and extension. +
+
+ So if you want to embed the file, it's recommended to + use the raw link instead of the download link. +

+

Ref

+

+ This information is based on the onedrive-vercel-index + project documentation. +
+ + Customise Direct Link - onedrive-vercel-index + +

+
+ + + + + +
+
+ )} +
+
+ {showRaw ? ( + + ) : null} + + +
+
+
+ + + File Information + + +
+ {fileInfo.map((info) => ( +
+ {info.label}: + + {info.value} + + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/@preview.audio.tsx b/src/app/@preview.audio.tsx new file mode 100644 index 0000000..eaf7e51 --- /dev/null +++ b/src/app/@preview.audio.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import AudioPlayer from "react-h5-audio-player"; +import "react-h5-audio-player/lib/styles.css"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; + +import { CreateDownloadToken } from "./actions"; +import "./r5-style.css"; + +type Props = { + file: z.infer; +}; +export default function PreviewAudio({ file }: Props) { + const [audioSrc, setAudioSrc] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + (async () => { + try { + if (!file.encryptedWebContentLink) { + setError("No audio to preview"); + return; + } + const token = await CreateDownloadToken(); + setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`); + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file]); + + return ( +
+ {loading ? ( +
+ +

Loading player...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( +
+ { + console.error(e); + setError( + "Could not preview this audio, try downloading the file", + ); + }} + /> +
+ )} +
+ ); +} diff --git a/src/app/@preview.doc.tsx b/src/app/@preview.doc.tsx new file mode 100644 index 0000000..e048992 --- /dev/null +++ b/src/app/@preview.doc.tsx @@ -0,0 +1,122 @@ +"use client"; + +import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + file: z.infer; +}; + +export default function PreviewDoc({ file }: Props) { + const [docSrc, setDocSrc] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + (async () => { + try { + if (!file.encryptedWebContentLink) { + setError("Nothing to preview"); + return; + } + const token = await CreateDownloadToken(); + setDocSrc(`/api/download/${file.encryptedId}?token=${token}`); + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file]); + + return ( +
+ {loading ? ( +
+ +

Loading document...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( + ( +
+ +

Loading document...

+
+ ), + showLoadingTimeout: 10000, + }, + noRenderer: { + overrideComponent: () => ( +
+ + + Error loading document + +
+ ), + }, + }} + className={cn( + "h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black", + )} + theme={{ + disableThemeScrollbar: true, + }} + /> + )} +
+ ); +} diff --git a/src/app/@preview.image.tsx b/src/app/@preview.image.tsx new file mode 100644 index 0000000..033a882 --- /dev/null +++ b/src/app/@preview.image.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + file: z.infer; +}; +export default function PreviewImage({ file }: Props) { + const [imgSrc, setImgSrc] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + (async () => { + try { + if (!file.encryptedWebContentLink) { + setError("No image to preview"); + return; + } + const token = await CreateDownloadToken(); + await fetch(`/api/download/${file.encryptedId}?token=${token}`) + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch image"); + return res.blob(); + }) + .then((blob) => { + const reader = new FileReader(); + reader.onload = () => { + setImgSrc(reader.result as string); + }; + reader.onerror = (e) => { + console.error(e); + setError( + "Could not preview this image, try downloading the file", + ); + }; + reader.readAsDataURL(blob); + }) + .catch((e) => { + console.error(e.message); + setError(e.message); + }); + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file]); + + return ( +
+ {loading ? ( +
+ +

Loading image...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( + {file.name} + )} +
+ ); +} diff --git a/src/app/@preview.manga.tsx b/src/app/@preview.manga.tsx new file mode 100644 index 0000000..447329c --- /dev/null +++ b/src/app/@preview.manga.tsx @@ -0,0 +1,172 @@ +"use client"; + +import JSZip from "jszip"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { + Carousel, + CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "~/components/ui/carousel"; + +import useMediaQuery from "~/hooks/useMediaQuery"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + file: z.infer; +}; + +export default function PreviewManga({ file }: Props) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [images, setImages] = useState<{ name: string; blob: string }[]>([]); + const [currentImage, setCurrentImage] = useState(1); + const [viewSize, setViewSize] = useState<"fit" | "full">("fit"); + const [api, setApi] = useState(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + useEffect(() => { + (async () => { + try { + if (!file.encryptedWebContentLink) { + setError("No video to preview"); + return; + } + const token = await CreateDownloadToken(); + const manga = await fetch( + `/api/download/${file.encryptedId}?token=${token}`, + ); + const archiveBlob = await manga.blob(); + const zipData = await JSZip.loadAsync(archiveBlob); + + const tempArray: { name: string; blob: string }[] = []; + const files = Object.values(zipData.files); + for (const file of files) { + const f = zipData.file(file.name); + if (!f) continue; + const blob = await f.async("blob"); + const reader = new FileReader(); + reader.onload = () => { + setImages((prev) => { + const exist = prev.find((p) => p.name === file.name); + if (exist) return prev; + return [ + ...prev, + { name: file.name, blob: reader.result as string }, + ]; + }); + }; + reader.readAsDataURL(blob); + } + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file]); + + useEffect(() => { + if (!api) return; + + api.on("select", (embla, e) => { + const index = embla.selectedScrollSnap(); + setCurrentImage(index + 1); + }); + }, [api]); + + return ( +
+ {loading ? ( +
+ +

Loading manga content...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( +
+
+ + + {images.map((image, index) => ( + + {`${image.name} + + {image.name} + + + ))} + + {isDesktop ? ( + <> + + + + ) : null} + +
+
+ + {currentImage}/{images.length} + +
setViewSize(viewSize === "fit" ? "full" : "fit")} + > + +
+
+
+ )} +
+ ); +} diff --git a/src/app/@preview.rich.tsx b/src/app/@preview.rich.tsx new file mode 100644 index 0000000..ff2095f --- /dev/null +++ b/src/app/@preview.rich.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; + +import Markdown from "./@markdown"; +import { GetContent } from "./actions"; + +type Props = { + file: z.infer; + code?: boolean; +}; +export default function PreviewRich({ file, code }: Props) { + const [content, setContent] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [expand, setExpand] = useState(false); + + useEffect(() => { + (async () => { + try { + const text = await GetContent(file.encryptedId); + if (!text) { + setError("Looks like there is no content to preview"); + return; + } + if (code) { + setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``); + } else { + setContent(text); + } + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file, code]); + + return ( +
+ {loading ? ( +
+ +

Loading content...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( +
+
+ +
+
+ +
+
+ )} +
+ ); +} diff --git a/src/app/@preview.unknown.tsx b/src/app/@preview.unknown.tsx new file mode 100644 index 0000000..5f8cde0 --- /dev/null +++ b/src/app/@preview.unknown.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; + +export default function PreviewUnknown() { + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(false); + }, []); + + return ( +
+ {loading ? ( +
+ +

Loading image...

+
+ ) : ( +
+ +

Preview not available

+

+ This file type is not supported for preview, try downloading the + file instead. +

+
+ )} +
+ ); +} diff --git a/src/app/@preview.video.tsx b/src/app/@preview.video.tsx new file mode 100644 index 0000000..ae5605a --- /dev/null +++ b/src/app/@preview.video.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ReactPlayer from "react-player"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; + +import { CreateDownloadToken } from "./actions"; + +type Props = { + file: z.infer; +}; +export default function PreviewVideo({ file }: Props) { + const [videoSrc, setVideoSrc] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + (async () => { + try { + if (!file.encryptedWebContentLink) { + setError("No video to preview"); + return; + } + const token = await CreateDownloadToken(); + setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`); + } catch (error) { + const e = error as Error; + console.error(e); + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, [file]); + + return ( +
+ {loading ? ( +
+ +

Loading video...

+
+ ) : error ? ( +
+ + {error} +
+ ) : ( + ( +
+ {children} +
+ )} + style={{ + width: "100%", + height: "100%", + maxHeight: "60vh", + }} + onError={(error) => { + console.error(error.message); + if (error instanceof Error) { + setError(error.message); + } else { + setError("An unknown error occurred"); + } + }} + /> + )} +
+ ); +} diff --git a/src/app/[...rest]/page.tsx b/src/app/[...rest]/page.tsx new file mode 100644 index 0000000..97baa60 --- /dev/null +++ b/src/app/[...rest]/page.tsx @@ -0,0 +1,216 @@ +import { Metadata, ResolvedMetadata } from "next"; +import { notFound } from "next/navigation"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; +import { cn } from "~/utils"; + +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Separator } from "~/components/ui/separator"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import gdrive from "~/utils/gdriveInstance"; +import { getFileType } from "~/utils/previewHelper"; + +import FileBrowser from "../@explorer"; +import Header from "../@header"; +import HeaderButton from "../@header.button"; +import Markdown from "../@markdown"; +import Password from "../@password"; +import PreviewAction from "../@preview.action"; +import PreviewAudio from "../@preview.audio"; +import PreviewDoc from "../@preview.doc"; +import PreviewImage from "../@preview.image"; +import PreviewManga from "../@preview.manga"; +import PreviewRich from "../@preview.rich"; +import PreviewUnknown from "../@preview.unknown"; +import PreviewVideo from "../@preview.video"; +import { + CheckPassword, + CheckPaths, + GetBanner, + GetFile, + GetFiles, + GetReadme, +} from "../actions"; + +export const revalidate = 300; +export const dynamic = "force-dynamic"; + +type Props = { + params: { + rest: string[]; + }; +}; + +export async function generateMetadata( + { params: { rest } }: Props, + parent: ResolvedMetadata, +): Promise { + const paths = await CheckPaths(rest); + if (!paths.success) return { title: "Not Found" }; + + const encryptedId = paths.data.pop()?.id; + if (!encryptedId) return { title: "Not Found" }; + const data = await GetFile(encryptedId); + + const banner = await GetBanner(encryptedId); + + return { + title: data.name, + description: data.mimeType?.includes("folder") + ? `Browse ${data.name} files` + : `View ${data.name}`, + openGraph: { + images: banner + ? [ + { + url: `/api/og/${banner}`, + // url: `/api/og/${ + // data.mimeType.startsWith("image") ? data.encryptedId : banner + // }`, + width: 1200, + height: 630, + }, + ] + : parent.openGraph?.images, + }, + }; +} + +export default async function RestPage({ params: { rest } }: Props) { + const paths = await CheckPaths(rest); + if (!paths.success) notFound(); + const unlocked = await CheckPassword(paths.data); + + if (!unlocked.success) { + if (!unlocked.path) + throw new Error("No path returned from password checking"); + return ( + + ); + } + + const encryptedId = paths.data.pop()?.id; + if (!encryptedId) + throw new Error("Failed to get encrypted ID, try to refresh the page."); + + const promise = []; + const { data: file } = await gdrive.files.get({ + fileId: await decryptData(encryptedId), + fields: "mimeType, fileExtension", + }); + if (!file.mimeType?.includes("folder")) { + promise.push(GetFile(encryptedId)); + } else { + promise.push(GetFiles({ id: encryptedId })); + } + promise.push(GetReadme(encryptedId)); + + const [data, readme] = await Promise.all(promise).then((values) => { + const file = Schema_File.safeParse(values[0]); + + if (file.success) { + return values as [z.infer, string]; + } else { + return values as [ + { files: z.infer[]; nextPageToken?: string }, + string, + ]; + } + }); + let fileType; + if (file.fileExtension && file.mimeType) { + fileType = getFileType(file.fileExtension, file.mimeType); + } + const isFile = !("files" in data); + + return ( +
+
({ + label: decodeURIComponent(item), + href: index === array.length - 1 ? undefined : `${item}`, + }))} + /> +
+ + + {isFile ? ( +
+ + {data.name} + +
+ ) : ( +
+ Browse files + +
+ )} + +
+ + {isFile ? ( +
+ {fileType === "image" ? ( + + ) : fileType === "audio" ? ( + + ) : fileType === "video" ? ( + + ) : fileType === "code" ? ( + + ) : fileType === "text" ? ( + + ) : fileType === "markdown" ? ( + + ) : fileType === "document" ? ( + + ) : fileType === "pdf" ? ( + + ) : fileType === "manga" ? ( + + ) : ( + + )} +
+ ) : ( + + )} +
+
+
+ {readme && ( +
+ + + README.md + + + + + + +
+ )} + {isFile && } +
+ ); +} diff --git a/src/app/actions.ts b/src/app/actions.ts new file mode 100644 index 0000000..1dc7f91 --- /dev/null +++ b/src/app/actions.ts @@ -0,0 +1,701 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { z } from "zod"; +import { Schema_File } from "~/schema"; + +import { decryptData, encryptData } from "~/utils/encryptionHelper/hash"; +import gdrive from "~/utils/gdriveInstance"; + +import { Constant } from "~/types/constant"; + +import config from "~/config/gIndex.config"; + +export async function CheckSitePassword(): Promise<{ + success: boolean; + message?: string; +}> { + try { + // Skip if the index is public + if (!config.siteConfig.privateIndex) + return { + success: true, + }; + if (!process.env.SITE_PASSWORD) + throw new Error( + "Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file", + ); + + const store = cookies(); + if (!store.has(Constant.cookies_SitePassword)) + return { + success: false, + }; + const password = store.get(Constant.cookies_SitePassword)?.value || ""; + const decryptedPassword = await decryptData(password); + if (decryptedPassword !== process.env.SITE_PASSWORD) + throw new Error( + "Saved password is incorrect, please re-enter the password", + ); + + return { + success: true, + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} +export async function SetSitePassword(password: string): Promise<{ + success: boolean; + message: string; +}> { + try { + const store = cookies(); + const encryptedPassword = await encryptData(password); + store.set(Constant.cookies_SitePassword, encryptedPassword, { + path: "/", + secure: true, + sameSite: "strict", + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + maxAge: 1000 * 60 * 60 * 24 * 365, + }); + revalidatePath("/", "layout"); + + return { + success: true, + message: "Password set", + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} + +export async function ClearPassword(): Promise<{ + success: boolean; + message: string; +}> { + try { + const store = cookies(); + if (store.has(Constant.cookies_FolderPassword)) { + store.delete(Constant.cookies_FolderPassword); + } + if (store.has(Constant.cookies_SitePassword)) { + store.delete(Constant.cookies_SitePassword); + } + return { + success: true, + message: "Password cleared", + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} + +export async function CheckPaths(paths: string[]): Promise< + | { + success: false; + message?: string; + } + | { + success: true; + data: { path: string; id: string }[]; + message?: string; + } +> { + try { + const promises = []; + for (const path of paths) { + promises.push( + gdrive.files + .list({ + q: `name = '${decodeURIComponent(path)}' and trashed = false`, + fields: "files(id, name, mimeType, parents)", + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }) + .then(({ data }) => { + if (!data.files?.length) return null; + return { + path, + data: data.files.map((file) => ({ + id: file.id, + parents: file.parents?.[0], + mimeType: file.mimeType, + })), + }; + }), + ); + } + + const data = await Promise.all(promises); + const notFoundIndex = data.findIndex((item) => !item); + if (notFoundIndex !== -1) + throw new Error(`Path not found: ${paths[notFoundIndex]}`); + + // Check if each path is valid + let valid = true; + let invalidPath: string | undefined; + const decryptedRootId = await decryptData(config.apiConfig.rootFolder); + for (const [index, path] of data.entries()) { + if (!valid) break; + // if first path, check if it's in root + if (index === 0) { + if (path?.data[0].parents === decryptedRootId) break; + } else { + if (path?.data[0].parents === data[index - 1]?.data[0].id) break; + } + valid = false; + invalidPath = data[index]?.path; + } + if (!valid) throw new Error(`Invalid path: ${invalidPath}`); + + const ids: { path: string; id: string }[] = []; + for (const item of data) { + if (item) { + const encryptedId = await encryptData(item.data[0].id as string); + ids.push({ + path: decodeURIComponent(item.path), + id: encryptedId, + }); + } + } + return { + success: true, + data: ids, + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} +export async function CheckPassword( + paths: { path: string; id: string }[], +): Promise< + | { + success: false; + message?: string; + path?: string; + } + | { + success: true; + message?: string; + } +> { + try { + const ids: string[] = []; + for (const path of paths) { + ids.push(await decryptData(path.id)); + } + const query: string[] = [ + `name = '${config.apiConfig.specialFile.password}'`, + `trashed = false`, + `(${ids.map((id) => `'${id}' in parents`).join(" or ")})`, // Filter by paths id + ]; + const { data: password } = await gdrive.files.list({ + q: query.join(" and "), + fields: "files(id, name, mimeType, parents)", + pageSize: 1000, + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }); + + // To save processing time, skip if password file not found + if (!password.files?.length) return { success: true }; + + const protectedFolder = password.files + .filter((file) => ids.includes(file.parents?.[0] as string)) + ?.shift(); // Shift to get only the nearest folder + + // To save processing time, skip if all of the paths are not protected + if (!protectedFolder) return { success: true }; + + const store = cookies(); + const folderIndex = ids.findIndex( + (item) => item === protectedFolder.parents?.[0], + ); + + const cookiesValue = JSON.parse( + store.get(Constant.cookies_FolderPassword)?.value || "{}", + ); + const currentFolder = paths[folderIndex]; + if (!cookiesValue[currentFolder.id]) + throw { + message: `Password for '${currentFolder.path}' is not set, please enter the password`, + path: currentFolder.id, + }; + + const savedPassword = await decryptData(cookiesValue[currentFolder.id]); + const { data: passwordFile } = await gdrive.files.get( + { + fileId: protectedFolder.id as string, + alt: "media", + }, + { + responseType: "text", + }, + ); + if (savedPassword !== passwordFile) + throw { + message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`, + path: currentFolder.id, + }; + + return { + success: true, + }; + } catch (error) { + if (error instanceof Error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } else { + const e = error as { message: string; path: string }; + console.error(e.message); + return { + success: false, + message: e.message, + path: e.path, + }; + } + } +} +export async function SetPassword( + path: string, + password: string, +): Promise<{ + success: boolean; + message: string; +}> { + try { + const store = cookies(); + const cookiesValue = JSON.parse( + store.get(Constant.cookies_FolderPassword)?.value || "{}", + ); + const updatedValue = { + ...cookiesValue, + [path]: await encryptData(password), + }; + store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), { + path: "/", + secure: true, + sameSite: "strict", + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + maxAge: 1000 * 60 * 60 * 24 * 365, + }); + revalidatePath("/", "layout"); + + return { + success: true, + message: "Password set", + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} + +export async function GetFiles({ + id, + pageToken, +}: { + id?: string; + pageToken?: string; +}): Promise<{ + files: z.infer[]; + nextPageToken?: string; +}> { + try { + let decryptedId: string | undefined; + if (id) decryptedId = await decryptData(id); + else decryptedId = await decryptData(config.apiConfig.rootFolder); + + const filterName = config.apiConfig.hiddenFiles + .map((item) => `not name = '${item}'`) + .join(" and "); + const query: string = [ + ...config.apiConfig.defaultQuery, + `'${decryptedId}' in parents`, + `${filterName}`, + ].join(" and "); + + const data = await gdrive.files.list({ + q: query, + fields: `files(${config.apiConfig.defaultField}), nextPageToken`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: pageToken, + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }); + + const encryptedData: z.infer[] = []; + if (!data.data.files?.length) + return { files: [], nextPageToken: undefined }; + for (const file of data.data.files) { + encryptedData.push({ + mimeType: file.mimeType as string, + encryptedId: await encryptData(file.id as string), + name: file.name as string, + trashed: (file.trashed as boolean) ?? false, + modifiedTime: new Date( + file.modifiedTime as string, + ).toLocaleDateString(), + fileExtension: file.fileExtension || undefined, + encryptedWebContentLink: file.webContentLink + ? await encryptData(file.webContentLink) + : undefined, + size: file.size ? Number(file.size) : undefined, + thumbnailLink: file.thumbnailLink || undefined, + imageMediaMetadata: file.imageMediaMetadata + ? { + width: Number(file.imageMediaMetadata.width), + height: Number(file.imageMediaMetadata.height), + rotation: Number(file.imageMediaMetadata.rotation || 0), + } + : undefined, + videoMediaMetadata: file.videoMediaMetadata + ? { + width: Number(file.videoMediaMetadata.width), + height: Number(file.videoMediaMetadata.height), + durationMillis: Number(file.videoMediaMetadata.durationMillis), + } + : undefined, + }); + } + const parsedContent = Schema_File.array().parse(encryptedData); + + return { + files: parsedContent, + nextPageToken: data.data.nextPageToken ?? undefined, + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function GetFile( + encryptedId: string, +): Promise> { + try { + const decryptedId = await decryptData(encryptedId); + const { data: file } = await gdrive.files.get({ + fileId: decryptedId, + fields: config.apiConfig.defaultField, + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + + const payload: z.infer = { + encryptedId, + mimeType: file.mimeType as string, + name: file.name as string, + trashed: (file.trashed as boolean) ?? false, + modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(), + fileExtension: file.fileExtension || undefined, + encryptedWebContentLink: file.webContentLink + ? await encryptData(file.webContentLink) + : undefined, + size: file.size ? Number(file.size) : undefined, + thumbnailLink: file.thumbnailLink || undefined, + imageMediaMetadata: file.imageMediaMetadata + ? { + width: Number(file.imageMediaMetadata.width), + height: Number(file.imageMediaMetadata.height), + rotation: Number(file.imageMediaMetadata.rotation || 0), + } + : undefined, + videoMediaMetadata: file.videoMediaMetadata + ? { + width: Number(file.videoMediaMetadata.width), + height: Number(file.videoMediaMetadata.height), + durationMillis: Number(file.videoMediaMetadata.durationMillis), + } + : undefined, + }; + const parsedContent = Schema_File.parse(payload); + + return parsedContent; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function SearchFile( + keyword: string, + nextPageToken?: string, +): Promise<{ + files: z.infer[]; + nextPageToken?: string; +}> { + try { + const query: string[] = [ + ...config.apiConfig.defaultQuery, + `name contains '${keyword}'`, + config.apiConfig.hiddenFiles + .map((item) => `not name = '${item}'`) + .join(" and "), + ]; + const data = await gdrive.files.list({ + q: query.join(" and "), + fields: `files(${config.apiConfig.defaultField}), nextPageToken`, + orderBy: "name_natural desc", + pageSize: config.apiConfig.searchResult, + pageToken: nextPageToken, + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }); + const encryptedData: z.infer[] = []; + if (!data.data.files?.length) + return { files: [], nextPageToken: undefined }; + for (const file of data.data.files) { + encryptedData.push({ + mimeType: file.mimeType as string, + encryptedId: await encryptData(file.id as string), + name: file.name as string, + trashed: (file.trashed as boolean) ?? false, + modifiedTime: new Date( + file.modifiedTime as string, + ).toLocaleDateString(), + fileExtension: file.fileExtension || undefined, + encryptedWebContentLink: file.webContentLink + ? await encryptData(file.webContentLink) + : undefined, + size: file.size ? Number(file.size) : undefined, + thumbnailLink: file.thumbnailLink || undefined, + imageMediaMetadata: file.imageMediaMetadata + ? { + width: Number(file.imageMediaMetadata.width), + height: Number(file.imageMediaMetadata.height), + rotation: Number(file.imageMediaMetadata.rotation || 0), + } + : undefined, + videoMediaMetadata: file.videoMediaMetadata + ? { + width: Number(file.videoMediaMetadata.width), + height: Number(file.videoMediaMetadata.height), + durationMillis: Number(file.videoMediaMetadata.durationMillis), + } + : undefined, + }); + } + const parsedContent = Schema_File.array().parse(encryptedData); + + return { + files: parsedContent, + nextPageToken: data.data.nextPageToken ?? undefined, + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function RedirectSearchFile(encryptedId: string): Promise { + try { + const { data } = await gdrive.files.get({ + fileId: await decryptData(encryptedId), + fields: "id, name, parents", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + + const paths: string[] = [data.name as string]; + let parentId = data.parents?.[0]; + const decryptedRootId = await decryptData(config.apiConfig.rootFolder); + while (parentId) { + if (parentId === decryptedRootId) break; + const { data: parent } = await gdrive.files.get({ + fileId: parentId, + fields: "id, name, parents", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + paths.unshift(parent.name as string); + parentId = parent.parents?.[0]; + } + + return paths.join("/"); + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} + +export async function GetContent(encryptedId: string): Promise { + try { + const decryptedId = await decryptData(encryptedId); + const { data: file } = await gdrive.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "text", + }, + ); + + if (typeof file !== "string") + throw new Error("It seems the file is not a text file"); + + return file as string; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function GetReadme( + encryptedId: string | undefined, +): Promise { + try { + let decryptedId; + if (encryptedId) decryptedId = await decryptData(encryptedId); + else decryptedId = await decryptData(config.apiConfig.rootFolder); + + const query: string[] = [ + ...config.apiConfig.defaultQuery, + `name = '${config.apiConfig.specialFile.readme}'`, + `'${decryptedId}' in parents`, + ]; + const { data } = await gdrive.files.list({ + q: query.join(" and "), + fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: undefined, + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }); + if (!data.files?.length) return null; + + const { data: content } = await gdrive.files.get( + { + fileId: data.files[0].id as string, + alt: "media", + }, + { + responseType: "text", + }, + ); + + return content as string; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function GetBanner( + encryptedId: string | undefined, +): Promise { + try { + let decryptedId; + if (encryptedId) decryptedId = await decryptData(encryptedId); + else decryptedId = await decryptData(config.apiConfig.rootFolder); + + const query: string[] = [ + ...config.apiConfig.defaultQuery, + `name contains '${config.apiConfig.specialFile.banner}'`, + `'${decryptedId}' in parents`, + ]; + const { data } = await gdrive.files.list({ + q: query.join(" and "), + fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: undefined, + supportsAllDrives: config.apiConfig.isTeamDrive, + includeItemsFromAllDrives: config.apiConfig.isTeamDrive, + }); + if (!data.files?.length) return null; + + return await encryptData(data.files[0].id as string); + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} + +export async function GetWebContent(encrypted: string): Promise { + try { + return await decryptData(encrypted); + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function CreateDownloadToken( + duration: number = config.apiConfig.temporaryTokenDuration, +): Promise { + try { + const data = { + expiredAt: + Date.now() + + duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration, + }; + const token = await encryptData(JSON.stringify(data)); + return token; + } catch (error) { + const e = error as Error; + console.error(e.message); + throw new Error(e.message); + } +} +export async function CheckDownloadToken(token: string): Promise<{ + success: boolean; + message?: string; +}> { + try { + const decryptToken = JSON.parse(await decryptData(token)); + if (decryptToken.expiredAt < Date.now()) { + return { + success: false, + message: + "Download token expired, please click the download button again to get a new token", + }; + } + + return { + success: true, + }; + } catch (error) { + const e = error as Error; + console.error(e.message); + return { + success: false, + message: e.message, + }; + } +} diff --git a/src/app/api/download/[encryptedId]/[fileName]/route.ts b/src/app/api/download/[encryptedId]/[fileName]/route.ts deleted file mode 100644 index f64bd9e..0000000 --- a/src/app/api/download/[encryptedId]/[fileName]/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import gIndexConfig from "config"; -import { NextRequest, NextResponse } from "next/server"; - -import getSearchParams from "utils/apiHelper/getSearchParams"; -import { decryptData } from "utils/encryptionHelper/hash"; -import ExtendedError from "utils/extendedError"; -import gdrive from "utils/gdriveInstance"; -import { isDownloadTokenValid } from "utils/tokenHelper"; - -import { ErrorResponse } from "types/api/response"; - -export async function GET( - request: NextRequest, - { - params, - }: { - params: { - encryptedId: string; - fileName: string; - }; - }, -) { - const reqStart = Date.now(); - const { encryptedId, fileName } = params; - try { - const data = JSON.parse(decryptData(encryptedId)) as { - id: string; - isProtected: boolean; - }; - const { token, preview, disableLimit } = getSearchParams(request.url, ["token", "preview", "disableLimit"]); - - if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && !token) - throw new ExtendedError("Missing download token", 403, "You are not authorized to download this file"); - if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && token && !isDownloadTokenValid(token)) - throw new ExtendedError("Invalid download token", 403, "You are not authorized to download this file"); - - const _fileMeta = gdrive.files.get({ - fileId: decryptData(data.id), - fields: "id, name, size, mimeType, webContentLink", - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }); - const _fileContent = gdrive.files.get( - { - fileId: decryptData(data.id), - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - alt: "media", - }, - { responseType: "stream" }, - ); - - const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]); - const fileSize = Number(fileMeta.data.size) ?? 0; - if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) { - return NextResponse.redirect(fileMeta.data.webContentLink!, { - status: 302, - headers: { - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } - const fileBuffer = await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - fileContent.data.on("data", (chunk) => { - chunks.push(chunk); - }); - fileContent.data.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - fileContent.data.on("error", (err) => { - reject(err); - }); - }); - - return new NextResponse(fileBuffer, { - status: 200, - headers: { - "Content-Type": fileMeta.data.mimeType ?? "application/octet-stream", - "Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`, - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } catch (error: any) { - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: error.code || 500, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: error.code || 500, - }); - } -} diff --git a/src/app/api/download/[encryptedId]/route.ts b/src/app/api/download/[encryptedId]/route.ts new file mode 100644 index 0000000..bba8c4e --- /dev/null +++ b/src/app/api/download/[encryptedId]/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CheckDownloadToken, + CheckPassword, + CheckPaths, + CheckSitePassword, + RedirectSearchFile, +} from "~/app/actions"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance"; + +import config from "~/config/gIndex.config"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { + params: { encryptedId }, + }: { + params: { + encryptedId: string; + }; + }, +) { + try { + const sp = new URL(request.nextUrl).searchParams; + const token = sp.get("token"); + if (!token) throw new Error("Token not found"); + + const tokenValidity = await CheckDownloadToken(token); + if (!tokenValidity.success) throw new Error(tokenValidity.message); + + if (config.siteConfig.privateIndex) { + const unlocked = await CheckSitePassword(); + if (!unlocked.success) { + return new NextResponse( + `It seems like this site is protected by password, and you haven't entered the password yet. + +If you've already entered the password, please make sure your browser is not blocking cookies from this site.`, + { + status: 401, + }, + ); + } + } + + const decryptedId = await decryptData(encryptedId); + const _filePaths = RedirectSearchFile(encryptedId); + const _fileMeta = gdrive.files.get({ + fileId: decryptedId, + fields: "id, name, mimeType, fileExtension, webContentLink", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + const _fileContent = gdrive.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "stream", + }, + ); + + const [fileMeta, fileContent, filePaths] = await Promise.all([ + _fileMeta, + _fileContent, + _filePaths, + ]); + if (!config.apiConfig.allowDownloadProtectedFile) { + const checkPath = await CheckPaths(filePaths.split("/")); + if (!checkPath.success) throw new Error("File not found"); + const unlocked = await CheckPassword(checkPath.data); + if (!unlocked.success) { + if (!unlocked.path) + throw new Error("No path returned from password checking"); + + const lockedIndex = checkPath.data.findIndex( + (path) => path.id === unlocked.path, + ); + // Get all path until the locked index, then join them + const path = checkPath.data + .slice(0, lockedIndex + 1) + .map((path) => path.path) + .join("/"); + return new NextResponse( + `The file you're trying to access is protected by password. +Please open the file link and enter the password to access the file, then try to download the file again. + +Protected Path: ${new URL(path, config.basePath).toString()} + +If you've already entered the password, please make sure your browser is not blocking cookies from this site.`, + { + status: 401, + }, + ); + } + } + + const fileSize = Number(fileMeta.data.size || 0); + if (!fileMeta.data.webContentLink) + throw new Error("No download link found"); + + if ( + config.apiConfig.maxFileSize && + fileSize > config.apiConfig.maxFileSize + ) { + return NextResponse.redirect(fileMeta.data.webContentLink, { + status: 302, + headers: { + ...request.headers, + "Cache-Control": config.cacheControl, + }, + }); + } + + const fileBuffer = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + fileContent.data.on("data", (chunk) => { + chunks.push(chunk); + }); + fileContent.data.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + fileContent.data.on("error", (err) => { + reject(err); + }); + }); + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + ...request.headers, + "Content-Type": fileMeta.data.mimeType || "application/octet-stream", + "Content-Length": fileBuffer.length.toString(), + "Content-Disposition": `attachment; filename="${encodeURIComponent( + fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, + )}"`, + "Cache-Control": config.cacheControl, + }, + }); + // const data = await GetFile(encryptedId); + // if (data.mimeType?.includes("folder")) + // throw new Error("Can't download folder"); + // if (!data.encryptedWebContentLink) + // throw new Error("No download link found"); + + // const decryptedWebContent = await decryptData(data.encryptedWebContentLink); + // return new NextResponse(null, { + // status: 302, + // headers: { + // Location: decryptedWebContent, + // }, + // }); + } catch (error) { + const e = error as Error; + console.error(e.message); + return new NextResponse(e.message, { + status: 500, + }); + } +} diff --git a/src/app/api/getData/route.ts b/src/app/api/getData/route.ts deleted file mode 100644 index 004ae48..0000000 --- a/src/app/api/getData/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -import gIndexConfig from "config"; -import { drive_v3 } from "googleapis"; -import { NextRequest, NextResponse } from "next/server"; - -import getSearchParams from "utils/apiHelper/getSearchParams"; -import { decryptData, encryptData } from "utils/encryptionHelper/hash"; -import ExtendedError from "utils/extendedError"; -import { gdriveFilesList } from "utils/gdrive"; -import gdrive from "utils/gdriveInstance"; - -import { IGDriveFiles } from "types/api/files"; -import { APIGetFileResponse, ErrorResponse } from "types/api/response"; - -function isHiddenFile(name: string) { - return gIndexConfig.apiConfig.hiddenFiles.find((item) => name.startsWith(item)) ? true : false; -} -function generateFileObject(data: drive_v3.Schema$File): IGDriveFiles { - return { - mimeType: data.mimeType as string, - fileExtension: (data.fileExtension as string) ?? null, - encryptedId: encryptData(data.id as string), - name: data.name as string, - trashed: (data.trashed as boolean) ?? false, - modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(), - encryptedWebContentLink: encryptData(data.webContentLink as string), - size: Number(data.size ?? 0), - thumbnailLink: (data.thumbnailLink as string) ?? null, - imageMediaMetadata: data.imageMediaMetadata - ? { - width: Number(data.imageMediaMetadata.width ?? 0), - height: Number(data.imageMediaMetadata.height ?? 0), - rotation: Number(data.imageMediaMetadata.rotation ?? 0), - } - : null, - videoMediaMetadata: data.videoMediaMetadata - ? { - width: Number(data.videoMediaMetadata.width ?? 0), - height: Number(data.videoMediaMetadata.height ?? 0), - durationMillis: Number(data.videoMediaMetadata.durationMillis ?? 0), - } - : null, - }; -} -function generateFolderObject(data: drive_v3.Schema$File): IGDriveFiles { - return { - mimeType: data.mimeType as string, - encryptedId: encryptData(data.id as string), - name: data.name as string, - trashed: (data.trashed as boolean) ?? false, - modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(), - }; -} - -export async function GET(request: NextRequest) { - const reqStart = Date.now(); - try { - const { encryptedId, isFile, pageToken } = getSearchParams(request.url, ["encryptedId", "isFile", "pageToken"]); - - let data: { - file: IGDriveFiles | null; - files: IGDriveFiles[]; - folders: IGDriveFiles[]; - pageToken: string | null; - } = { - file: null, - files: [], - folders: [], - pageToken: null, - }; - if (isFile && encryptedId) { - const fileContent = await gdrive.files.get({ - fileId: decryptData(encryptedId), - fields: gIndexConfig.apiConfig.defaultField, - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }); - if (isHiddenFile(fileContent.data.name as string)) - throw new ExtendedError("File not found", 404, "File you are looking for is not found"); - - const payload: IGDriveFiles = generateFileObject(fileContent.data); - data.file = payload; - } else { - const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and "); - const query: string[] = [ - ...gIndexConfig.apiConfig.defaultQuery, - `'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`, - `${filterName}`, - ]; - const folderContent = await gdriveFilesList({ - q: query.join(" and "), - fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, - orderBy: gIndexConfig.apiConfig.defaultOrder, - pageSize: gIndexConfig.apiConfig.itemsPerPage, - pageToken: pageToken ?? undefined, - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - includeItemsFromAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }); - - const listFolders: IGDriveFiles[] = - folderContent.data.files - ?.filter((item) => item.mimeType === "application/vnd.google-apps.folder") - .map((item) => generateFolderObject(item)) ?? []; - const listFiles: IGDriveFiles[] = - folderContent.data.files - ?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder") - .map((item) => generateFileObject(item)) ?? []; - - data = { - ...data, - files: listFiles, - folders: listFolders, - pageToken: folderContent.data.nextPageToken ?? null, - }; - } - - const payload: APIGetFileResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - data, - }; - - return NextResponse.json(payload, { - status: 200, - headers: { - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } catch (error: any) { - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: error.code || 500, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: error.code || 500, - }); - } -} diff --git a/src/app/api/getPassword/route.ts b/src/app/api/getPassword/route.ts deleted file mode 100644 index d3a1afa..0000000 --- a/src/app/api/getPassword/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import gIndexConfig from "config"; -import { NextRequest, NextResponse } from "next/server"; - -import getSearchParams from "utils/apiHelper/getSearchParams"; -import { decryptData, encryptData } from "utils/encryptionHelper/hash"; -import ExtendedError from "utils/extendedError"; -import { gdriveFilesList } from "utils/gdrive"; -import gdrive from "utils/gdriveInstance"; - -import { APIGetPasswordResponse, ErrorResponse } from "types/api/response"; - -export async function GET(request: NextRequest) { - const reqStart = Date.now(); - - try { - const { path } = getSearchParams(request.url, ["path"]); - if (!path) throw new ExtendedError("Path is required", 400, "Can't find path in query, please check your query"); - const mappedPath: Record<"name" | "id" | "mimeType", string>[] = JSON.parse(decryptData(path)); - const passwordParents = mappedPath.map((path) => `'${decryptData(path.id)}' in parents`); - const query: string[] = [ - "trashed = false", - `name = '${gIndexConfig.apiConfig.specialFile.password}' and (${passwordParents.join(" or ")})`, - ]; - const passwordData = await gdriveFilesList({ - q: query.join(" and "), - fields: "files(id, name, parents)", - }); - - const protectedPaths = mappedPath.map((path) => { - const isPasswordExist = passwordData.data.files?.find((file) => file.parents?.[0] === decryptData(path.id)); - if (isPasswordExist) - return { - name: path.name, - passwordId: isPasswordExist.id, - }; - }); - - const _passwordContent: Promise<{ - path: string; - password: string; - }>[] = []; - protectedPaths.forEach((path) => { - if (!path) return; - _passwordContent.push( - gdrive.files - .get( - { - fileId: path.passwordId as string, - alt: "media", - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }, - { responseType: "text" }, - ) - .then((res) => ({ - path: path.name as string, - password: res.data as string, - })), - ); - }); - - const passwordContent = await Promise.all(_passwordContent); - - let prevPath = [""]; - let password: Record<"relativePath" | "password", string>[] = []; - - mappedPath.forEach((path) => { - prevPath.push(path.name); - const isPasswordExist = passwordContent.find((password) => password.path === path.name); - if (isPasswordExist) { - password.push({ - relativePath: prevPath.join("/"), - password: encryptData(isPasswordExist.password), - }); - } - }); - - const payload: APIGetPasswordResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - data: password, - }; - - return NextResponse.json(payload, { - status: 200, - headers: { - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } catch (error: any) { - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: error.code || 500, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: error.code || 500, - }); - } -} diff --git a/src/app/api/getReadme/route.ts b/src/app/api/getReadme/route.ts deleted file mode 100644 index cb4b52e..0000000 --- a/src/app/api/getReadme/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import gIndexConfig from "config"; -import { NextRequest, NextResponse } from "next/server"; - -import getSearchParams from "utils/apiHelper/getSearchParams"; -import { decryptData } from "utils/encryptionHelper/hash"; -import { gdriveFilesList } from "utils/gdrive"; -import gdrive from "utils/gdriveInstance"; - -import { APIGetReadmeResponse, ErrorResponse } from "types/api/response"; - -export async function GET(request: NextRequest) { - const reqStart = Date.now(); - try { - const { encryptedId } = getSearchParams(request.url, ["encryptedId"]); - - const query: string[] = [ - ...gIndexConfig.apiConfig.defaultQuery, - `'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`, - ]; - const folderContent = await gdriveFilesList({ - q: query.join(" and "), - fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, - orderBy: gIndexConfig.apiConfig.defaultOrder, - pageSize: gIndexConfig.apiConfig.itemsPerPage, - pageToken: undefined, - }); - const isReadmeExist = folderContent.data.files?.find( - (file) => file.name === gIndexConfig.apiConfig.specialFile.readme, - ); - let data: string | null = null; - - if (isReadmeExist) { - const fileContent = await gdrive.files.get( - { - fileId: isReadmeExist.id as string, - alt: "media", - }, - { responseType: "text" }, - ); - data = fileContent.data as string; - } - - const payload: APIGetReadmeResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - data, - }; - - return NextResponse.json(payload, { - status: 200, - headers: { - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } catch (error: any) { - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: error.code || 500, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: error.code || 500, - }); - } -} diff --git a/src/app/api/internal/encrypt/route.ts b/src/app/api/internal/encrypt/route.ts new file mode 100644 index 0000000..c62f22e --- /dev/null +++ b/src/app/api/internal/encrypt/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { encryptData } from "~/utils/encryptionHelper/hash"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const sp = new URL(request.nextUrl).searchParams; + const query = sp.get("q"); + if (!query) + return new NextResponse( + "Add query parameter 'q' with the value to encrypt", + { status: 400 }, + ); + + const encrypted = await encryptData(query); + + return NextResponse.json( + { + value: encrypted, + }, + { status: 200 }, + ); + } catch (error) { + const e = error as Error; + console.error(e); + return new Response(e.message, { status: 500 }); + } +} diff --git a/src/app/api/og/[encryptedId]/route.ts b/src/app/api/og/[encryptedId]/route.ts new file mode 100644 index 0000000..a569935 --- /dev/null +++ b/src/app/api/og/[encryptedId]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import gdrive from "~/utils/gdriveInstance"; + +import config from "~/config/gIndex.config"; + +type Props = { + params: { + encryptedId: string; + }; +}; + +export async function GET( + request: NextRequest, + { params: { encryptedId } }: Props, +) { + try { + const decryptedId = await decryptData(encryptedId); + const { data } = await gdrive.files.get({ + fileId: decryptedId, + fields: "id, name, mimeType, webContentLink", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + if (!data.webContentLink) throw new Error("No thumbnail for this file"); + const downloadThumb = await fetch(data.webContentLink, { + cache: "force-cache", + }); + const buffer = await downloadThumb.arrayBuffer(); + const bufferData = Buffer.from(buffer); + + return new NextResponse(bufferData, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": data.mimeType || "application/octet-stream", + "Content-Length": bufferData.length.toString(), + "Content-Disposition": `inline; filename="${data.name}"`, + }, + }); + } catch (error) { + const e = error as Error; + console.error(e.message); + return NextResponse.json( + { + error: e.message, + }, + { + status: 500, + }, + ); + } +} diff --git a/src/app/api/raw/[...rest]/route.ts b/src/app/api/raw/[...rest]/route.ts new file mode 100644 index 0000000..832c865 --- /dev/null +++ b/src/app/api/raw/[...rest]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CheckPassword, CheckPaths, GetFile } from "~/app/actions"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; + +import config from "~/config/gIndex.config"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params: { rest } }: { params: { rest: string[] } }, +) { + try { + const sp = new URL(request.nextUrl).searchParams; + const token = sp.get("token"); + if (!token) throw new Error("Token not found"); + + const paths = await CheckPaths(rest); + if (!paths.success) throw new Error(paths.message); + + if (!config.apiConfig.allowDownloadProtectedFile) { + const unlocked = await CheckPassword(paths.data); + if (!unlocked.success) + throw new Error( + unlocked.path + ? unlocked.message + : "No path returned from password checking", + ); + } + + const encryptedId = paths.data.pop()?.id; + if (!encryptedId) + throw new Error("Failed to get encrypted ID, try to refresh the page."); + if (token !== encryptedId) throw new Error("Invalid token"); + + const data = await GetFile(encryptedId); + if (data.mimeType?.includes("folder")) + throw new Error("Can't download folder"); + if ( + !data.mimeType.includes("video") && + !data.mimeType.includes("image") && + !data.mimeType.includes("audio") + ) + throw new Error( + "Raw link only available for video, image, and audio files", + ); + if (!data.encryptedWebContentLink) + throw new Error("No download link found"); + + const decryptedWebContent = await decryptData(data.encryptedWebContentLink); + return new NextResponse(null, { + status: 302, + headers: { + Location: decryptedWebContent, + }, + }); + } catch (error) { + const e = error as Error; + console.error(e.message); + return new NextResponse(e.message, { + status: 500, + }); + } +} diff --git a/src/app/api/search/redirect/[encryptedId]/route.ts b/src/app/api/search/redirect/[encryptedId]/route.ts deleted file mode 100644 index 40bc12d..0000000 --- a/src/app/api/search/redirect/[encryptedId]/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import gIndexConfig from "config"; -import { NextRequest, NextResponse } from "next/server"; - -import { decryptData } from "utils/encryptionHelper/hash"; -import ExtendedError from "utils/extendedError"; -import gdrive from "utils/gdriveInstance"; - -import { ErrorResponse } from "types/api/response"; - -export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) { - const reqStart = Date.now(); - const { encryptedId } = params; - try { - const id = decryptData(encryptedId); - if (!id) throw new ExtendedError("Invalid encryptedId", 400, "EncryptedId provided is invalid"); - - console.log(id); - const path: string[] = []; - let lastId = id; - const fileContent = await gdrive.files.get({ - fileId: lastId, - fields: "id, name, mimeType, parents", - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }); - if (fileContent) { - path.push(fileContent.data.name as string); - if (fileContent.data.parents) { - lastId = fileContent.data.parents[0] as string; - while (lastId) { - const folderContent = await gdrive.files.get({ - fileId: lastId, - fields: "id, name, mimeType, parents", - supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive, - }); - if (folderContent.data.id === gIndexConfig.apiConfig.rootFolder) { - lastId = ""; - break; - } - if (folderContent) { - path.push(folderContent.data.name as string); - if (folderContent.data.parents) { - if (folderContent.data.parents[0] === gIndexConfig.apiConfig.rootFolder) { - lastId = ""; - break; - } else { - lastId = folderContent.data.parents[0] as string; - } - } else { - lastId = ""; - } - } else { - lastId = ""; - } - } - } - } - - const redirectURL = new URL(path.reverse().join("/"), request.nextUrl.origin ?? process.env.VERCEL_URL); - console.log(redirectURL); - return NextResponse.redirect(redirectURL, { - status: 302, - headers: { - "Cache-Control": gIndexConfig.cacheControl, - }, - }); - } catch (error: any) { - const errorCode = isNaN(Number(error.code)) ? 500 : Number(error.code); - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: errorCode, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: errorCode, - }); - } -} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts deleted file mode 100644 index 2b448ba..0000000 --- a/src/app/api/search/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import gIndexConfig from "config"; -import { NextRequest, NextResponse } from "next/server"; - -import getSearchParams from "utils/apiHelper/getSearchParams"; -import { encryptData } from "utils/encryptionHelper/hash"; -import ExtendedError from "utils/extendedError"; -import { gdriveFilesList } from "utils/gdrive"; - -import { IGDriveFiles } from "types/api/files"; -import { APISearchResponse, ErrorResponse } from "types/api/response"; - -export async function GET(request: NextRequest) { - const reqStart = Date.now(); - try { - const { query } = getSearchParams(request.url, ["query"]); - if (!query) throw new ExtendedError("Missing query", 400, "Search query is required"); - - const searchFiles = await gdriveFilesList({ - q: [...gIndexConfig.apiConfig.defaultQuery, `name contains '${query}'`].join(" and "), - fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, - orderBy: "name_natural desc", - pageSize: gIndexConfig.apiConfig.searchResult, - pageToken: undefined, - }); - - const files: (IGDriveFiles & { redirect: string })[] = - searchFiles.data.files?.map((data) => ({ - mimeType: data.mimeType ?? "Unknown", - encryptedId: encryptData(data.id as string), - name: data.name as string, - trashed: data.trashed ?? false, - modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(), - fileExtension: (data.fileExtension as string) ?? null, - encryptedWebContentLink: undefined, - size: Number(data.size) ?? 0, - thumbnailLink: (data.thumbnailLink as string) ?? null, - imageMediaMetadata: null, - videoMediaMetadata: null, - redirect: `/api/search/redirect/${encryptData(data.id as string)}`, - })) ?? []; - - const payload: APISearchResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - files: files, - }; - - return NextResponse.json(payload, { - status: 200, - }); - } catch (error: any) { - const res: ErrorResponse = { - timestamp: Date.now(), - responseTime: Date.now() - reqStart, - error: { - code: error.code || 500, - message: error.message, - reason: error.errors?.[0].reason, - }, - }; - return NextResponse.json(res, { - status: error.code || 500, - }); - } -} diff --git a/src/app/api/thumb/[encryptedId]/route.ts b/src/app/api/thumb/[encryptedId]/route.ts new file mode 100644 index 0000000..ed0cd05 --- /dev/null +++ b/src/app/api/thumb/[encryptedId]/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import gdrive from "~/utils/gdriveInstance"; + +import config from "~/config/gIndex.config"; + +type Props = { + params: { + encryptedId: string; + }; +}; + +export async function GET( + request: NextRequest, + { params: { encryptedId } }: Props, +) { + try { + const defaultImage = NextResponse.redirect( + new URL("/og.png", config.basePath), + { + status: 302, + }, + ); + const decryptedId = await decryptData(encryptedId); + const _fileMeta = gdrive.files.get({ + fileId: decryptedId, + fields: + "id, name, mimeType, fileExtension, webContentLink, thumbnailLink", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + const _fileContent = gdrive.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "stream", + }, + ); + + const [fileMeta, fileContent] = await Promise.all([ + _fileMeta, + _fileContent, + ]); + const fileSize = Number(fileMeta.data.size || 0); + + if (!fileMeta.data.webContentLink) return defaultImage; + if (!fileMeta.data.thumbnailLink) return defaultImage; + if ( + !fileMeta.data.mimeType?.startsWith("image") && + !fileMeta.data.mimeType?.startsWith("video") + ) + return defaultImage; + + // If svg, return actual image since there is no thumbnail for svg + if ( + fileMeta.data.mimeType?.includes("svg") && + fileSize <= config.apiConfig.maxFileSize + ) { + const fileBuffer = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + fileContent.data.on("data", (chunk) => { + chunks.push(chunk); + }); + fileContent.data.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + fileContent.data.on("error", (err) => { + reject(err); + }); + }); + + return new NextResponse(fileBuffer, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": fileMeta.data.mimeType || "application/octet-stream", + "Content-Length": fileBuffer.length.toString(), + "Content-Disposition": `attachment; filename="${encodeURIComponent( + fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, + )}"`, + }, + }); + } + + if (config.apiConfig.proxyThumbnail) { + const downloadThumb = await fetch(fileMeta.data.thumbnailLink, { + cache: "force-cache", + }); + const buffer = await downloadThumb.arrayBuffer(); + + return new NextResponse(buffer, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": fileMeta.data.mimeType || "application/octet-stream", + "Content-Length": buffer.byteLength.toString(), + "Content-Disposition": `attachment; filename="${encodeURIComponent( + fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, + )}"`, + }, + }); + } + + return NextResponse.redirect(fileMeta.data.thumbnailLink); + } catch (error) { + const e = error as Error; + console.error(e.message); + return NextResponse.json( + { + error: e.message, + }, + { + status: 500, + }, + ); + } +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..f35a1dc --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { cn } from "~/utils"; + +import Icon from "~/components/Icon"; +import { Button } from "~/components/ui/button"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const router = useRouter(); + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ +
+ + Something went wrong + + + More details can be found in the console + +
+ + {error.message} + + +
+ + +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..c528669 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,117 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Shadcn/ui theme */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 89.8%; + /* --ring: 0 0% 3.9%; */ + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 14.9%; + /* --ring: 0 0% 83.1%; */ + } +} + +html, +body { + margin: 0; + padding: 0; + box-sizing: border-box; +} +@layer base { + :root { + @apply text-[14px] tablet:text-[16px]; + } + * { + @apply border-border; + /* @apply outline outline-1 outline-red-500; */ + } + body { + @apply bg-background text-foreground; + } + + ::-webkit-scrollbar { + @apply h-1.5 w-1.5; + } + ::-webkit-scrollbar-track { + @apply bg-background; + } + ::-webkit-scrollbar-thumb { + @apply rounded-full bg-primary/25 hover:bg-primary/50; + } + + /* Typography */ + h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; + } + h2 { + @apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0; + } + h3 { + @apply scroll-m-20 text-2xl font-semibold tracking-tight; + } + h4 { + @apply scroll-m-20 text-xl font-semibold tracking-tight; + } + .paragraph { + @apply leading-7 [&:not(:first-child)]:mt-6; + } + blockquote { + @apply mt-6 border-l-2 pl-6 italic; + } + ul { + @apply my-6 ml-6 list-disc [&>li]:mt-2; + } + .lead { + @apply text-xl text-muted-foreground; + } + .large { + @apply text-lg font-semibold; + } + .muted { + @apply text-sm text-muted-foreground; + } + small { + @apply text-sm font-medium leading-none; + } +} diff --git a/src/app/highlight.css b/src/app/highlight.css new file mode 100644 index 0000000..cf8359e --- /dev/null +++ b/src/app/highlight.css @@ -0,0 +1,472 @@ +:root { + /** + * One Light theme for prism.js + * Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax + */ + /* From colors.less */ + --mono-1: hsl(230, 8%, 24%); + --mono-2: hsl(230, 6%, 44%); + --mono-3: hsl(230, 4%, 64%); + --hue-1: hsl(198, 99%, 37%); + --hue-2: hsl(221, 87%, 60%); + --hue-3: hsl(301, 63%, 40%); + --hue-4: hsl(119, 34%, 47%); + --hue-5: hsl(5, 74%, 59%); + --hue-5-2: hsl(344, 84%, 43%); + --hue-6: hsl(35, 99%, 36%); + --hue-6-2: hsl(35, 99%, 40%); + --syntax-fg: hsl(230, 8%, 24%); + --syntax-bg: hsl(230, 1%, 98%); + --syntax-gutter: hsl(230, 1%, 62%); + --syntax-guide: hsla(230, 8%, 24%, 0.2); + --syntax-accent: hsl(230, 100%, 66%); + /* From syntax-variables.less */ + --syntax-selection-color: hsl(230, 1%, 90%); + --syntax-gutter-background-color-selected: hsl(230, 1%, 90%); + --syntax-cursor-line: hsla(230, 8%, 24%, 0.05); +} + +.dark { + /** + * One Dark theme for prism.js + * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax + */ + /* From colors.less */ + --mono-1: hsl(220, 14%, 71%); + --mono-2: hsl(220, 9%, 55%); + --mono-3: hsl(220, 10%, 40%); + --hue-1: hsl(187, 47%, 55%); + --hue-2: hsl(207, 82%, 66%); + --hue-3: hsl(286, 60%, 67%); + --hue-4: hsl(95, 38%, 62%); + --hue-5: hsl(355, 65%, 65%); + --hue-5-2: hsl(5, 48%, 51%); + --hue-6: hsl(29, 54%, 61%); + --hue-6-2: hsl(39, 67%, 69%); + --syntax-fg: hsl(220, 14%, 71%); + --syntax-bg: hsl(220, 13%, 18%); + --syntax-gutter: hsl(220, 14%, 45%); + --syntax-guide: hsla(220, 14%, 71%, 0.15); + --syntax-accent: hsl(220, 100%, 66%); + /* From syntax-variables.less */ + --syntax-selection-color: hsl(220, 13%, 28%); + --syntax-gutter-background-color-selected: hsl(220, 13%, 26%); + --syntax-cursor-line: hsla(220, 100%, 80%, 0.04); +} + +code[class*="language-"], +pre[class*="language-"], +pre { + background: var(--syntax-bg); + color: var(--syntax-fg); + text-shadow: 0 1px rgba(0, 0, 0, 0); + font-size: inherit; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 2; + tab-size: 2; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Selection */ +code[class*="language-"]::-moz-selection, +code[class*="language-"] *::-moz-selection, +pre[class*="language-"] *::-moz-selection { + background: var(--syntax-selection-color); + color: inherit; + text-shadow: none; +} + +code[class*="language-"]::selection, +code[class*="language-"] *::selection, +pre[class*="language-"] *::selection { + background: var(--syntax-selection-color); + color: inherit; + text-shadow: none; +} + +/* Code blocks */ +pre[class*="language-"], +pre { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.2em 0.3em; + border-radius: 0.3em; + white-space: normal; +} + +/* Print */ +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +.token.comment, +.token.prolog, +.token.cdata { + color: var(--mono-3); +} + +.token.doctype, +.token.punctuation, +.token.entity { + color: var(--syntax-fg); +} + +.token.attr-name, +.token.class-name, +.token.boolean, +.token.constant, +.token.number, +.token.atrule { + color: var(--hue-6); +} + +.token.keyword { + color: var(--hue-3); +} + +.token.property, +.token.tag, +.token.symbol, +.token.deleted, +.token.important { + color: var(--hue-5); +} + +.token.selector, +.token.string, +.token.char, +.token.builtin, +.token.inserted, +.token.regex, +.token.attr-value, +.token.attr-value > .token.punctuation { + color: var(--hue-4); +} + +.token.variable, +.token.operator, +.token.function { + color: var(--hue-2); +} + +.token.url { + color: var(--hue-1); +} + +/* HTML overrides */ +.token.attr-value > .token.punctuation.attr-equals, +.token.special-attr > .token.attr-value > .token.value.css { + color: var(--syntax-fg); +} + +/* CSS overrides */ +.language-css .token.selector { + color: var(--hue-5); +} + +.language-css .token.property { + color: var(--syntax-fg); +} + +.language-css .token.function, +.language-css .token.url > .token.function { + color: var(--hue-1); +} + +.language-css .token.url > .token.string.url { + color: var(--hue-4); +} + +.language-css .token.important, +.language-css .token.atrule .token.rule { + color: var(--hue-3); +} + +/* JS overrides */ +.language-javascript .token.operator { + color: var(--hue-3); +} + +.language-javascript + .token.template-string + > .token.interpolation + > .token.interpolation-punctuation.punctuation { + color: var(--hue-5-2); +} + +/* JSON overrides */ +.language-json .token.operator { + color: var(--syntax-fg); +} + +.language-json .token.null.keyword { + color: var(--hue-6); +} + +/* MD overrides */ +.language-markdown .token.url, +.language-markdown .token.url > .token.operator, +.language-markdown .token.url-reference.url > .token.string { + color: var(--syntax-fg); +} + +.language-markdown .token.url > .token.content { + color: var(--hue-2); +} + +.language-markdown .token.url > .token.url, +.language-markdown .token.url-reference.url { + color: var(--hue-1); +} + +.language-markdown .token.blockquote.punctuation, +.language-markdown .token.hr.punctuation { + color: var(--mono-3); + font-style: italic; +} + +.language-markdown .token.code-snippet { + color: var(--hue-4); +} + +.language-markdown .token.bold .token.content { + color: var(--hue-6); +} + +.language-markdown .token.italic .token.content { + color: var(--hue-3); +} + +.language-markdown .token.strike .token.content, +.language-markdown .token.strike .token.punctuation, +.language-markdown .token.list.punctuation, +.language-markdown .token.title.important > .token.punctuation { + color: var(--hue-5); +} + +/* General */ +.token.bold { + font-weight: bold; +} + +.token.comment, +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.namespace { + opacity: 0.8; +} + +/* Plugin overrides */ +/* Selectors should have higher specificity than those in the plugins' default stylesheets */ + +/* Show Invisible plugin overrides */ +.token.token.tab:not(:empty):before, +.token.token.cr:before, +.token.token.lf:before, +.token.token.space:before { + color: var(--syntax-guide); + text-shadow: none; +} + +/* Toolbar plugin overrides */ +/* Space out all buttons and move them away from the right edge of the code block */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item { + margin-right: 0.4em; +} + +/* Styling the buttons */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span { + background: var(--syntax-gutter-background-color-selected); + color: var(--mono-2); + padding: 0.1em 0.4em; + border-radius: 0.3em; +} + +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus { + background: var(--syntax-selection-color); + color: var(--syntax-fg); +} + +/* Line Highlight plugin overrides */ +/* The highlighted line itself */ +.line-highlight.line-highlight { + background: var(--syntax-cursor-line); +} + +/* Default line numbers in Line Highlight plugin */ +.line-highlight.line-highlight:before, +.line-highlight.line-highlight[data-end]:after { + background: var(--syntax-gutter-background-color-selected); + color: var(--syntax-fg); + padding: 0.1em 0.6em; + border-radius: 0.3em; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */ +} + +/* Hovering over a linkable line number (in the gutter area) */ +/* Requires Line Numbers plugin as well */ +pre[id].linkable-line-numbers.linkable-line-numbers + span.line-numbers-rows + > span:hover:before { + background-color: var(--syntax-cursor-line); +} + +/* Line Numbers and Command Line plugins overrides */ +/* Line separating gutter from coding area */ +.line-numbers.line-numbers .line-numbers-rows, +.command-line .command-line-prompt { + border-right-color: var(--syntax-guide); +} + +/* Stuff in the gutter */ +.line-numbers .line-numbers-rows > span:before, +.command-line .command-line-prompt > span:before { + color: var(--syntax-gutter); +} + +/* Match Braces plugin overrides */ +/* Note: Outline colour is inherited from the braces */ +.rainbow-braces .token.token.punctuation.brace-level-1, +.rainbow-braces .token.token.punctuation.brace-level-5, +.rainbow-braces .token.token.punctuation.brace-level-9 { + color: var(--hue-5); +} + +.rainbow-braces .token.token.punctuation.brace-level-2, +.rainbow-braces .token.token.punctuation.brace-level-6, +.rainbow-braces .token.token.punctuation.brace-level-10 { + color: var(--hue-4); +} + +.rainbow-braces .token.token.punctuation.brace-level-3, +.rainbow-braces .token.token.punctuation.brace-level-7, +.rainbow-braces .token.token.punctuation.brace-level-11 { + color: var(--hue-2); +} + +.rainbow-braces .token.token.punctuation.brace-level-4, +.rainbow-braces .token.token.punctuation.brace-level-8, +.rainbow-braces .token.token.punctuation.brace-level-12 { + color: var(--hue-3); +} + +/* Diff Highlight plugin overrides */ +/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */ +pre.diff-highlight > code .token.token.deleted:not(.prefix), +pre > code.diff-highlight .token.token.deleted:not(.prefix) { + background-color: hsla(353, 100%, 66%, 0.15); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix), +pre > code.diff-highlight .token.token.inserted:not(.prefix) { + background-color: hsla(137, 100%, 55%, 0.15); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +/* Previewers plugin overrides */ +/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */ +/* Border around popup */ +.prism-previewer.prism-previewer:before, +.prism-previewer-gradient.prism-previewer-gradient div { + border-color: hsl(224, 13%, 17%); +} + +/* Angle and time should remain as circles and are hence not included */ +.prism-previewer-color.prism-previewer-color:before, +.prism-previewer-gradient.prism-previewer-gradient div, +.prism-previewer-easing.prism-previewer-easing:before { + border-radius: 0.3em; +} + +/* Triangles pointing to the code */ +.prism-previewer.prism-previewer:after { + border-top-color: hsl(224, 13%, 17%); +} + +.prism-previewer-flipped.prism-previewer-flipped.after { + border-bottom-color: hsl(224, 13%, 17%); +} + +/* Background colour within the popup */ +.prism-previewer-angle.prism-previewer-angle:before, +.prism-previewer-time.prism-previewer-time:before, +.prism-previewer-easing.prism-previewer-easing { + background: hsl(219, 13%, 22%); +} + +/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */ +/* For time, this is the alternate colour */ +.prism-previewer-angle.prism-previewer-angle circle, +.prism-previewer-time.prism-previewer-time circle { + stroke: var(--syntax-fg); + stroke-opacity: 1; +} + +/* Stroke colours of the handle, direction point, and vector itself */ +.prism-previewer-easing.prism-previewer-easing circle, +.prism-previewer-easing.prism-previewer-easing path, +.prism-previewer-easing.prism-previewer-easing line { + stroke: var(--syntax-fg); +} + +/* Fill colour of the handle */ +.prism-previewer-easing.prism-previewer-easing circle { + fill: transparent; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..8be26be --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,131 @@ +import { Metadata } from "next"; +import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; +import { cn } from "~/utils"; + +import config from "~/config/gIndex.config"; + +import Footer from "./@footer"; +import Navbar from "./@navbar"; +import Password from "./@password"; +import { CheckSitePassword } from "./actions"; +import "./globals.css"; +import "./markdown.css"; +import ThemeProvider from "./theme-provider"; + +const sourceSans3 = Source_Sans_3({ + weight: ["300", "400", "600", "700"], + style: ["normal", "italic"], + display: "auto", + subsets: ["latin", "latin-ext"], + variable: "--font-source-sans-3", +}); +const jetbrainsMono = JetBrains_Mono({ + weight: ["300", "400", "600", "700"], + style: ["normal", "italic"], + display: "auto", + subsets: ["latin", "latin-ext"], + variable: "--font-jetbrains-mono", +}); + +export const metadata: Metadata = { + metadataBase: new URL(config.basePath), + title: { + default: config.siteConfig.siteName, + template: config.siteConfig.siteNameTemplate || "%s", + }, + description: config.siteConfig.siteDescription, + authors: config.siteConfig.siteAuthor + ? { + name: config.siteConfig.siteAuthor, + } + : undefined, + creator: "mbaharip", + icons: [ + { + url: config.siteConfig.favIcon, + }, + ], + keywords: ["gdrive", "index", "nextjs", "reactjs"], + openGraph: { + type: "website", + siteName: config.siteConfig.siteName, + images: [ + { + url: "/og.png", + width: 1200, + height: 630, + }, + ], + }, + twitter: { + card: "summary_large_image", + creator: config.siteConfig.twitterHandle, + }, + robots: config.siteConfig.robots, +}; + +export default async function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const unlocked = await CheckSitePassword(); + const formatFooter = (text: string | string[]): string => { + let toFormat: string; + if (Array.isArray(text)) { + toFormat = text.join(`\n\n`); + } else { + toFormat = text; + } + return toFormat + .replaceAll("{{ year }}", new Date().getFullYear().toString()) + .replaceAll( + "{{ repository }}", + "[Repository](https://github.com/mbaharip/next-gdrive-index)", + ) + .replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip") + .replaceAll("{{ version }}", config.version || "0.0.0") + .replaceAll("{{ siteName }}", config.siteConfig.siteName) + .replaceAll("{{ creator }}", "mbaharip"); + }; + + return ( + + + + +
+ {config.siteConfig.privateIndex && !unlocked.success ? ( + + ) : ( + <>{children} + )} +
+ {config.siteConfig.footer && ( +