diff --git a/.env.example b/.env.example index 8dfdad5..01eaa50 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ 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 +# Example: https://www.mbaharip.com -> mbaharip.com +NEXT_PUBLIC_DOMAIN= diff --git a/.eslintrc.json b/.eslintrc.json index f0f3abe..9ff6a67 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/**/*", "**/*/_pages/**/*"] } diff --git a/.gitignore b/.gitignore index 4f49267..5e899e5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,10 @@ next-env.d.ts # personal docs /docs /src/pages/api/legacy/ - /**/*/_legacy/ - +/data /.*/ -/src/_app \ No newline at end of file +/src/_app +/src/_pages +/src/utils/_legacy +/src/components/_legacy diff --git a/.prettierrc.js b/.prettierrc.js index cbd437c..90b13b7 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,6 +1,9 @@ "use strict"; module.exports = { - plugins: [require("@trivago/prettier-plugin-sort-imports"), require("prettier-plugin-tailwindcss")], + plugins: [ + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss", + ], printWidth: 80, tabWidth: 2, useTabs: false, @@ -17,15 +20,19 @@ 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)/(.*)$", + "^~/components/(.*)$", + "^~/(utils|hooks|context)/(.*)$", + "^~/types/(.*)$", + "^~/(config|styles)/(.*)$", "^[./]", ], importOrderSeparation: true, importOrderSortSpecifiers: true, - tailwindFunctions: ["twsx", "cn"], }; diff --git a/README.md b/README.md index a4f2f6b..09cdba6 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,64 @@ FAQ

-## Version 2 +## 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. +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: -- Upgrade to `Next.js 14` app directory structure. -- Use `shadcn/ui` for the UI components. -- Add light and dark mode. +### 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 `{{ masterKey }}`. + +### 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? @@ -54,30 +103,43 @@ 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.~~ + +~~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.~~ ### Shared Drive is now supported @@ -88,3 +150,39 @@ 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` 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/next.config.js b/next.config.js index 9f61070..f0754a2 100644 --- a/next.config.js +++ b/next.config.js @@ -1,21 +1,10 @@ -/** @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, + webpack: (config) => { + config.resolve.alias.canvas = false; + + return config; + }, // async headers() { // return [ // { @@ -31,5 +20,4 @@ const nextConfig = { // }, }; -// module.exports = withMDX(nextConfig); module.exports = nextConfig; diff --git a/package.json b/package.json index ffebd8e..3f04ce9 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "clsx": "^2.1.0", "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "embla-carousel-react": "^8.0.0", + "embla-carousel-react": "^8.0.1", "googleapis": "^118.0.0", "input-otp": "^1.2.3", "jsonwebtoken": "^9.0.0", @@ -95,8 +95,8 @@ "eslint": "8.38.0", "eslint-config-next": "^14.1.4", "postcss": "^8.4.38", - "prettier": "^2.8.7", - "prettier-plugin-tailwindcss": "^0.2.7", + "prettier": "3.0.0", + "prettier-plugin-tailwindcss": "0.5.12", "tailwindcss": "^3.4.1", "typescript": "5.0.4" } 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/_pages/404.tsx b/src/_pages/404.tsx index 5e403f0..b262bd5 100644 --- a/src/_pages/404.tsx +++ b/src/_pages/404.tsx @@ -1,7 +1,6 @@ -import Link from "next/link"; - import Button from "components/Button"; import LoaderLayout from "components/Layout/Loader"; +import Link from "next/link"; export default function NotFoundPage() { return ( @@ -10,24 +9,22 @@ export default function NotFoundPage() { title: "Not Found", }} > -
+
Page not found
-
+
The page you are trying to access is not found. - - Please check the URL or go back to the homepage. - + Please check the URL or go back to the homepage. -
+
+ )} + {!fileList.length && ( +
+ + + There are no files in this folder +
)} {layout === "list" && ( diff --git a/src/app/@file.grid.tsx b/src/app/@file.grid.tsx index 8d6ab34..4e6c7a7 100644 --- a/src/app/@file.grid.tsx +++ b/src/app/@file.grid.tsx @@ -2,34 +2,197 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; import { z } from "zod"; -import Icon from "~/components/Icon"; -import { Separator } from "~/components/ui/separator"; 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 path = [pathname, encodeURIComponent(data.name)].join("/"); + const path = [pathname, encodeURIComponent(data.name)] + .join("/") + .replace(/\/+/g, "/"); - // If start with /, remove it - if (path.startsWith("/")) { - return path.slice(1); - } else { - return path; - } + 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")) ? ( @@ -57,7 +220,12 @@ export default function FileGrid({ data }: Props) { {data.name} + {data.name} {data.mimeType.startsWith("video") && ( @@ -89,27 +257,37 @@ export default function FileGrid({ data }: Props) { {/* File data */}
- + {data.fileExtension ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") : data.name} - -
- - {new Date(data.modifiedTime).toLocaleDateString()} +
+ + {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 index 1e5a823..ae2a21a 100644 --- a/src/app/@file.list.tsx +++ b/src/app/@file.list.tsx @@ -2,23 +2,37 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useMemo } from "react"; +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 config from "~/config/gIndex.config"; -import { Schema_File } from "~/schema"; -import { cn } from "~/utils"; + +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 = { @@ -26,26 +40,170 @@ type Props = { }; export default function FileList({ data }: Props) { const pathname = usePathname(); - const filePath = useMemo(() => { - const path = [pathname, encodeURIComponent(data.name)].join("/"); - // If start with /, remove it - if (path.startsWith("/")) { - return path.slice(1); - } else { - return path; - } + const filePath = useMemo(() => { + 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 : ( + + + + )} +
+ + + + + + +
+
+ )} +
- {data.mimeType.includes("folder") ? null : data.mimeType} - -
-
- - {new Date(data.modifiedTime).toLocaleDateString()} + {data.mimeType.includes("folder") + ? "folder" + : data.fileExtension} {!data.mimeType.includes("folder") && ( <> {bytesToReadable(data.size || 0)} @@ -113,80 +268,14 @@ export default function FileList({ data }: Props) { )}
+
+ + {new Date(data.modifiedTime).toLocaleDateString()} + +
- - {/* Actions */} - - - - - - { - 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); - } - }} - > - Copy link - - { - 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}`, - }); - } - }} - > - Download - - -
- +
); } diff --git a/src/app/@header.breadcrumb.tsx b/src/app/@header.breadcrumb.tsx index a20ee38..d371315 100644 --- a/src/app/@header.breadcrumb.tsx +++ b/src/app/@header.breadcrumb.tsx @@ -4,6 +4,8 @@ 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, @@ -31,9 +33,10 @@ import { } from "~/components/ui/dropdown-menu"; import { Separator } from "~/components/ui/separator"; import { Skeleton } from "~/components/ui/skeleton"; -import config from "~/config/gIndex.config"; + import useMediaQuery from "~/hooks/useMediaQuery"; -import { Schema_Breadcrumb } from "~/schema"; + +import config from "~/config/gIndex.config"; type Props = { data: z.infer[]; @@ -43,137 +46,138 @@ export default function HeaderBreadcrumb({ data, loading }: Props) { const [open, setOpen] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); - if (loading) return ; + if (loading) return ; return (
- -
- - ~ -
+ + +
+ + ~ +
+
+
+ {!!data.length ? ( + <> + - {!!data.length ? ( - <> - - - {data.length > config.siteConfig.breadcrumbMax ? ( - <> - {isDesktop ? ( - - - - - - {data - .slice(0, -config.siteConfig.breadcrumbMax + 1) - .map((item, _, array) => ( - - config.siteConfig.breadcrumbMax ? ( + <> + {isDesktop ? ( + + + + + + {data + .slice(0, -config.siteConfig.breadcrumbMax + 1) + .map((item, _, array) => ( + + + item.href) - .join("/")} + .join("/")}`} className='w-full' > {item.label} - - - ))} - - - ) : ( - - - - - - - - Navigate to parent directories - - - -
- {data - .slice(0, -config.siteConfig.breadcrumbMax + 1) - .map((item, _, array) => ( - + + + + ))} + + + ) : ( + + + + + + + + Navigate to parent directories + + + +
+ {data + .slice(0, -config.siteConfig.breadcrumbMax + 1) + .map((item, _, array) => ( + + item.href) - .join("/")} + .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} - - - ) : ( - + + + ) : null} + + {data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => ( + + + {item.href ? ( + <> + + item.href) + .join("/") + .replace(/\/\//g, "/")}`} + > {item.label} - - )} - - {item.href && } - - ))} - - ) : null} - + +
+ + ) : ( + + {item.label} + + )} + + {item.href && } + + ))} + + ) : null}
diff --git a/src/app/@header.button.tsx b/src/app/@header.button.tsx index bdcb34a..ce31910 100644 --- a/src/app/@header.button.tsx +++ b/src/app/@header.button.tsx @@ -1,38 +1,115 @@ "use client"; -import { PropsWithChildren, useContext } from "react"; +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 { Input } from "~/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; + 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, TLayout } from "~/context/layoutContext"; -import { cn } from "~/utils"; -export default function HeaderButton({ - children, - loading, -}: PropsWithChildren<{ loading?: boolean }>) { +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 ( -
- {" "} +
+ {" "}
); @@ -41,57 +118,487 @@ export default function HeaderButton({ return (
- - - - + + + + + )} + {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") && ( + <> + + + )} + + ) : ( - - - - - Search - - Search for files in your drive - - -
- + )} +
+ + {/* 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 index 98723ef..3a9da84 100644 --- a/src/app/@header.title.tsx +++ b/src/app/@header.title.tsx @@ -1,6 +1,7 @@ "use client"; import { PropsWithChildren } from "react"; + import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, diff --git a/src/app/@header.tsx b/src/app/@header.tsx index 173d3c0..9ef6071 100644 --- a/src/app/@header.tsx +++ b/src/app/@header.tsx @@ -6,7 +6,6 @@ import { Schema_Breadcrumb } from "~/schema"; import { cn } from "~/utils"; import HeaderBreadcrumb from "./@header.breadcrumb"; -import HeaderButton from "./@header.button"; type Props = { name: string; @@ -38,7 +37,7 @@ export default function Header({ name, breadcrumb }: Props) { data={breadcrumb || []} loading={loading} /> - + {/* */}
); diff --git a/src/app/@markdown.tsx b/src/app/@markdown.tsx index a39b316..57c75c1 100644 --- a/src/app/@markdown.tsx +++ b/src/app/@markdown.tsx @@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import remarkSlug from "remark-slug"; import remarkToc from "remark-toc"; + import Icon from "~/components/Icon"; import "./highlight.css"; diff --git a/src/app/@navbar.tsx b/src/app/@navbar.tsx index 0baabae..77175df 100644 --- a/src/app/@navbar.tsx +++ b/src/app/@navbar.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import { usePathname } 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 { @@ -17,6 +19,16 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "~/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, @@ -31,9 +43,10 @@ import { SheetTrigger, } from "~/components/ui/sheet"; import { Tooltip, TooltipContent } from "~/components/ui/tooltip"; -import config from "~/config/gIndex.config"; + import useMediaQuery from "~/hooks/useMediaQuery"; -import { cn } from "~/utils"; + +import config from "~/config/gIndex.config"; import { ClearPassword } from "./actions"; @@ -43,6 +56,7 @@ export default function Navbar() { const { theme, themes, setTheme } = useTheme(); const [open, setOpen] = useState(false); + const [themeOpen, setThemeOpen] = useState(false); useEffect(() => { if (isDesktop) setOpen(false); @@ -65,27 +79,16 @@ export default function Navbar() { } return ( -
+
+ )}
diff --git a/src/app/@password.tsx b/src/app/@password.tsx index 0831685..3e3d0e7 100644 --- a/src/app/@password.tsx +++ b/src/app/@password.tsx @@ -3,12 +3,13 @@ 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 { cn } from "~/utils"; -import { CheckSitePassword, SetSitePassword } from "./actions"; +import { CheckSitePassword, SetPassword, SetSitePassword } from "./actions"; type Props = { path: string; @@ -29,11 +30,16 @@ export default function Password({ path, errorMessage }: Props) { try { if (!input) throw new Error("Password is required"); - const set = await SetSitePassword(input); - if (!set.success) throw new Error(set.message); + 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); + const check = await CheckSitePassword(); + if (!check.success) throw new Error(check.message); + } else { + const set = await SetPassword(path, input); + if (!set.success) throw new Error(set.message); + } toast.success("Password accepted! Refreshing...", { id: "password", 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..1f442e1 --- /dev/null +++ b/src/app/@preview.audio.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import AudioPlayer from "react-h5-audio-player"; +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 video...

+
+ ) : 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..044f066 --- /dev/null +++ b/src/app/[...rest]/page.tsx @@ -0,0 +1,214 @@ +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; + +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/_internal_/thumb/[encryptedId]/route.ts b/src/app/_internal_/thumb/[encryptedId]/route.ts deleted file mode 100644 index 3591649..0000000 --- a/src/app/_internal_/thumb/[encryptedId]/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function GET( - request: NextRequest, - { encryptedId }: { encryptedId: string }, -) { - return NextResponse.json({ - encryptedId, - }); -} diff --git a/src/app/actions.ts b/src/app/actions.ts index 7f0c0b9..ffcc2d8 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -3,12 +3,15 @@ import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; import { z } from "zod"; -import config from "~/config/gIndex.config"; import { Schema_File } from "~/schema"; -import { Constant } from "~/types/constant"; + 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; @@ -60,6 +63,8 @@ export async function SetSitePassword(password: string): Promise<{ secure: true, sameSite: "strict", httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + maxAge: 1000 * 60 * 60 * 24 * 365, }); revalidatePath("/", "layout"); @@ -103,6 +108,220 @@ export async function ClearPassword(): Promise<{ } } +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 new Error( + `Password for '${currentFolder.path}' is incorrect, please re-enter the password`, + ); + + 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, @@ -116,15 +335,13 @@ export async function GetFiles({ try { let decryptedId: string | undefined; if (id) decryptedId = await decryptData(id); - const filterName: string = config.apiConfig.hiddenFiles - .map((item) => `name != '${item}'`) + 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, - // `'1owGkP0NsJ4am8vVYebpjnoxzY5VOc7Vh' in parents`, - decryptedId - ? `'${decryptedId}' in parents` - : `'${config.apiConfig.rootFolder}' in parents`, + `'${decryptedId}' in parents`, `${filterName}`, ].join(" and "); @@ -160,7 +377,7 @@ export async function GetFiles({ ? { width: Number(file.imageMediaMetadata.width), height: Number(file.imageMediaMetadata.height), - rotation: Number(file.imageMediaMetadata.rotation), + rotation: Number(file.imageMediaMetadata.rotation || 0), } : undefined, videoMediaMetadata: file.videoMediaMetadata @@ -184,13 +401,184 @@ export async function GetFiles({ 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: string | undefined; + let decryptedId; if (encryptedId) decryptedId = await decryptData(encryptedId); - else decryptedId = config.apiConfig.rootFolder; + else decryptedId = await decryptData(config.apiConfig.rootFolder); const query: string[] = [ ...config.apiConfig.defaultQuery, @@ -199,10 +587,12 @@ export async function GetReadme( ]; const { data } = await gdrive.files.list({ q: query.join(" and "), - fields: `files(${config.apiConfig.defaultField}), nextPageToken`, + 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; @@ -223,13 +613,55 @@ export async function GetReadme( 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, + expiredAt: + Date.now() + + duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration, }; const token = await encryptData(JSON.stringify(data)); return token; @@ -239,7 +671,6 @@ export async function CreateDownloadToken( throw new Error(e.message); } } - export async function CheckDownloadToken(token: string): Promise<{ success: boolean; message?: string; 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..71018ea --- /dev/null +++ b/src/app/api/download/[encryptedId]/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CheckDownloadToken, + CheckPassword, + CheckPaths, + CheckSitePassword, + RedirectSearchFile, +} from "~/app/actions"; + +import { decryptData } from "~/utils/encryptionHelper/hash"; +import gdrive from "~/utils/gdriveInstance"; + +import config from "~/config/gIndex.config"; + +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 f80c68e..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..185855e --- /dev/null +++ b/src/app/api/internal/encrypt/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { encryptData } from "~/utils/encryptionHelper/hash"; + +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..3011be4 --- /dev/null +++ b/src/app/api/raw/[...rest]/route.ts @@ -0,0 +1,63 @@ +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 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 index df7a49e..ed0cd05 100644 --- a/src/app/api/thumb/[encryptedId]/route.ts +++ b/src/app/api/thumb/[encryptedId]/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import config from "~/config/gIndex.config"; + import { decryptData } from "~/utils/encryptionHelper/hash"; import gdrive from "~/utils/gdriveInstance"; +import config from "~/config/gIndex.config"; + type Props = { params: { encryptedId: string; @@ -14,14 +16,93 @@ export async function GET( { params: { encryptedId } }: Props, ) { try { + const defaultImage = NextResponse.redirect( + new URL("/og.png", config.basePath), + { + status: 302, + }, + ); const decryptedId = await decryptData(encryptedId); - const { data } = await gdrive.files.get({ + const _fileMeta = gdrive.files.get({ fileId: decryptedId, - fields: "id, name, mimeType, thumbnailLink", + fields: + "id, name, mimeType, fileExtension, webContentLink, thumbnailLink", supportsAllDrives: config.apiConfig.isTeamDrive, }); - if (!data.thumbnailLink) throw new Error("No thumbnail for this file"); - return NextResponse.redirect(data.thumbnailLink); + 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); diff --git a/src/app/api/thumb/route.ts b/src/app/api/thumb/route.ts deleted file mode 100644 index 41c503e..0000000 --- a/src/app/api/thumb/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - return NextResponse.json({ - message: "Hello World!", - }); -} 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 index 422e8f2..c528669 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -77,7 +77,7 @@ body { @apply bg-background; } ::-webkit-scrollbar-thumb { - @apply bg-primary/50 hover:bg-primary/75; + @apply rounded-full bg-primary/25 hover:bg-primary/50; } /* Typography */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9b9cb07..8be26be 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,9 @@ import { Metadata } from "next"; import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; -import config from "~/config/gIndex.config"; import { cn } from "~/utils"; +import config from "~/config/gIndex.config"; + import Footer from "./@footer"; import Navbar from "./@navbar"; import Password from "./@password"; @@ -41,7 +42,7 @@ export const metadata: Metadata = { creator: "mbaharip", icons: [ { - url: config.siteConfig.siteIcon, + url: config.siteConfig.favIcon, }, ], keywords: ["gdrive", "index", "nextjs", "reactjs"], @@ -90,7 +91,7 @@ export default async function RootLayout({ -
+
- -
- {config.siteConfig.privateIndex && !unlocked.success ? ( - - ) : ( - <>{children} - )} -
- {config.siteConfig.footer && ( -
+ {config.siteConfig.privateIndex && !unlocked.success ? ( + + ) : ( + <>{children} )} -
+ + {config.siteConfig.footer && ( +