diff --git a/.env-example b/.env-example deleted file mode 100644 index 6678578..0000000 --- a/.env-example +++ /dev/null @@ -1,7 +0,0 @@ -# You can generate encryption key from setup page. -NEXT_PUBLIC_ENCRYPTION_KEY="" -# You can use any random string for JWT_KEY, it is recommended to use a strong key by combining characters, numbers and symbols. -JWT_KEY="" -# Domain name of your website. Used for fetching the og-image from public directory. -# Example: https://drive.example.com -NEXT_PUBLIC_DOMAIN="" \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8dfdad5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +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 diff --git a/.gitignore b/.gitignore index bfc7379..4f49267 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ /coverage # next.js -/.next/ /out/ # production @@ -28,15 +27,15 @@ yarn-error.log* .env .env.local -# vercel -.vercel - # typescript *.tsbuildinfo next-env.d.ts # personal docs /docs -/.next -/.vscode /src/pages/api/legacy/ + +/**/*/_legacy/ + +/.*/ +/src/_app \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index d8e9561..0000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/drive-manager.iml b/.idea/drive-manager.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/drive-manager.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 5be0a6c..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 187b5d5..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml deleted file mode 100644 index b0ab31a..0000000 --- a/.idea/prettier.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js index 8d5469f..99bcabb 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,9 +1,10 @@ "use strict"; -const tailwindConfig = require("prettier-plugin-tailwindcss"); - module.exports = { - plugins: [tailwindConfig], - printWidth: 80, + plugins: [ + require("prettier-plugin-tailwindcss"), + require("@trivago/prettier-plugin-sort-imports"), + ], + printWidth: 120, tabWidth: 2, useTabs: false, semi: true, @@ -19,4 +20,14 @@ module.exports = { endOfLine: "lf", embeddedLanguageFormatting: "auto", singleAttributePerLine: true, + importOrder: [ + "", + "^components/(.*)$", + "^(utils|hooks|context)/(.*)$", + "^types/(.*)$", + "^(config|styles)/(.*)$", + "^[./]", + ], + importOrderSeparation: true, + importOrderSortSpecifiers: true, }; diff --git a/README.md b/README.md index 32c4b54..0c9f6f6 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,81 @@ ![next-gdrive-index](./public/og.png) -## Status - -Basic features are working now, can be used for daily use. -You can check the demo [here](https://drive.mbaharip.com). +

+ Demo + · + Deploying Guide + · + FAQ +

**Development will be paused for a few weeks, I will resume the project as soon as possible.** ## What is this? + `next-gdrive-index` is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive. It's aim to simplify the process of sharing files using Google Drive, and also implements some features that I think is useful for sharing files. This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by SpencerWooo. ### Why I made this? -I know there already some Google Drive indexer out there, but all of them are using **Cloudflare** worker. -I've tried to use it, but the speed isn't that great for me. -Currently I'm using SpencerWooo's onedrive index, and it's working perfectly for me from usage perspective and speed. -But, if I compare the price between these two services, Google Drive still cheaper than Onedrive. +when I compare the price between these two services, Google Drive still cheaper than Onedrive. For 100GB plan, Google Drive is IDR 269.000 / ~$18 USD annually, and Onedrive is IDR 319.000 / ~$21 USD annually. For free plan, Google Drive offering 15GB of storage, and Onedrive offering 5GB of storage. I know there are a lot of people selling cheap edu account for Google Drive and Onedrive, but most of the time those account doesn't last long, especially Google Drive account. So I want to do it legit without buying cheap edu account or anything similar this time. ## Features -### Navigate through your Google Drive files and folders. -You can navigate through your Google Drive files and folders, just like you're browsing your local files. -You can also set the root folder, so you can share only specific folder in your Google Drive. -### Theme support. -Choose your side! +- **Private Index**, lock the whole index site with password. +- **Folder and file protection**, you can protect your folder and files with password. +- **Readme file**, you can add readme file for your folder, so you can add some description for your folder. +- **File preview**, you can preview some file types directly from the browser, without downloading it first. + - Image preview + - Video preview + - Audio preview + - Markdown preview + - PDF preview + - Document preview (docx, pptx, xlsx) + - Code preview + - Text preview + - Manga preview (cbz) +- **File search**, you can search for files and folders easily. +- **Direct download**, you can download files directly from index site without opening Google Drive. +- **Raw file link**, you can use the raw file link for hosting your web project assets or comments on forum. -### Site wide password protection. -You can lock the site so only those who know the password can access it. -NOTE: This only protect the site, not the files. The files are still accessible if you know the link. +## File Security -### Search for files and folders. -We implement native search for files and folders, so you can search for files and folders easily. +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. -### File preview for some file types. -You can preview some file types directly from the browser, without downloading it first. -Most files are Images, Videos, Audio, Markdown, and PDF. (I'm still adding more file types) +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. -### Direct download for files. -Download files directly from index site without opening Google Drive. +## Known Issue -### Raw file link for files. -You can use the raw file link for hosting your web project assets or comments on forum. +### File size limit -### Embed your readme file for folder. -You can embed your readme file for folder, so you can add some description for your folder. -Simply upload `.readme.md` on your folder, and it will be displayed on the folder page. +File size limit causing some files can't be previewed, and the download will be redirected to the raw file link. -### Override folder OpenGraph image. -You can override the folder OpenGraph image by uploading `.banner.(png/jpg/jpeg/webp/svg)` on your folder. +### Long Response Time -## Credits -- [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by SpencerWooo for the inspiration and also some file type helpers. +The flow of the project are: ---- +- Validate the path is valid or not. (Only applied for `/...[path]`) +- Fetch the password, readme, and files from Google Drive. +- Show to the user. -## Known Issues -- ~~Fetching files from Google Drive API take too much time, it triggers 504 Error on Vercel.~~ -- ~~Can't download big files, because of Vercel's 4.5MB limit for serverless function.~~ (Implement redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB) - - Need to set the folder to public, so it can be downloaded directly from Google Drive. - - The way to get files are changed because of this, now it's using the file name, and 8 first character of file id for hiding the id so no one can access it via Google Drive. - - All id and webContentLink are now encrypted using AES-128-CBC. -- Protected folder cause long response time, because it need to fetch the file / folder first > fetch each of the parents till root > check if it's inside protected folder > then checking the password. -- Download might show scan warning, because it's using Google Drive's direct download link. +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. -## TODO -Todo list are moved to [here](./TODO.md). +ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive. + +### No support for Google Shared Drive + +I don't have Google Shared Drive, so I can't test it and implement it. + +### No support for Google Docs, Sheets, and Slides + +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. diff --git a/TODO.md b/TODO.md index 7776533..3f02df2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,109 +1,41 @@ -# Todo -Detailed what I need to do for this project. -This is just a reminder for me, so I don't forget what I need to do. +- Redo how to handle error -Probably also include some ideas that I want to implement in the future. +# / +- ~~Override opengraph using banner image~~ +- ~~Render readme file~~ +- ~~Copy folder url~~ +- ~~Copy file direct url~~ +- ~~Download file url~~ +- ~~Index password~~ -## Frontend -### General -- ~~Site wide password protection~~ -- Migrate to app directory (?) +# /: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 -### Navbar `8/8 Completed` -- ~~Search for files~~ - - ~~Open / Close search modal~~ - - ~~Search for files or folder~~ - - ~~Configurable search limit~~ - - ~~Close search modal when click outside of modal~~ -- ~~Theme~~ - - ~~Switch theme~~ - - ~~Store theme preference in local storage~~ -- ~~Navbar menu~~ - -### Layout Container `10/10 Completed` -This part contains only breadcrumb and view selection. - -- ~~Breadcrumb~~ - - ~~Fetch breadcrumb~~ - - ~~Navigate through breadcrumb~~ - - ~~Limit breadcrumb~~ - - ~~Add `...` if breadcrumb is too long~~ - - ~~Add ellipsis if current file name is too long~~ - - ~~Configurable breadcrumb limit~~ -- ~~Layout view~~ - - ~~Grid view~~ - - ~~List view~~ - - ~~Store view preference in local storage~~ - - ~~Close view selection when click outside~~ - -### Home `/` | `4/5 Completed` -- ~~Fetch files~~ -- ~~Fetch readme if exist~~ -- ~~Fetch banner if exist~~ -- ~~Pagination~~ -- Protected folder - -### Folder `/folder/:id` | `5/6 Completed` -- ~~Check if folder exist~~ -- ~~Fetch files~~ -- ~~Fetch readme if exist~~ -- ~~Fetch banner if exist~~ -- ~~Pagination~~ -- Protected folder - -### File `/file/:id` | `` - -## Backend -### General -Since Google Drive direct download need the file to be public, I implement the `partial file id` to keep people from directly accessing the Google Drive itself, and also encrypt the `id` and `webContentLink` from Google Drive API to ensure that no one can access the file directly from Google Drive. - -- ~~Change `fileId` to `{file name}:{partial file id}`~~ -- ~~Encrypt `id` and `webContentLink`~~ -- ~~Making sure response time is fast enough. Aim for max 1.5s.~~ - - ~~Fetch root folder `600~900ms`~~ - - ~~Fetch file or folder `700~1400ms`~~ - - ~~Fetch breadcrumb `900~1500ms`~~ - - ~~Fetch readme `1100~1200ms`~~ - - ~~Fetch readme from specific folder `1200~1500ms`~~ - - ~~Fetch banner `500-800ms`~~ - - ~~Fetch banner from specific folder `500~800ms`~~ - - Protect folder - -### Fetch files `/api/files` | `16/17 Completed` - -- ~~Fetch file~~ -- ~~Fetch folder~~ -- ~~Paginated~~ -- ~~Fetch info regarding readme file~~ -- ~~Fetch info regarding banner file~~ -- ~~Fetch info regarding password file~~ (Not used yet) -- Download file - - ~~Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB~~ - - ~~Only allow for files~~ - - Files inside protected folder -- ~~Get thumbnail for files~~ -- ~~Get banner inside folder~~ - - ~~Only if banner file is inside the folder~~ - - ~~Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB~~ - - ~~Check if banner file is image~~ -- ~~Generate breadcrumb~~ - - ~~On folder~~ - - ~~On file~~ - - ~~Hide root folder~~ - - ~~Limiting breadcrumb length~~ - -### Get readme data `/api/readme` | `2/2 Completed` -This API route doesn't need authorization, because it's only used for getting readme data. - -- ~~Get root folder readme~~ -- ~~Get specific folder readme~~ - -### Get banner file id `/api/banner` | `2/2 Completed` -This API route doesn't need authorization, because it's only used for getting banner file id for Opengraph Image. - -- ~~Get root folder banner~~ -- ~~Get specific folder banner~~ - -### Search for files `/api/search` | `2/2 Completed` -- ~~Search for files and folder~~ -- ~~Limit search result~~ \ No newline at end of file +# /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/data/mockFiles.ts b/data/mockFiles.ts new file mode 100644 index 0000000..6b435f9 --- /dev/null +++ b/data/mockFiles.ts @@ -0,0 +1,70 @@ +import { IGDriveFiles } from "types/api/files"; +import { APIFilesResponse } from "types/api/response"; + +const mockFiles: APIFilesResponse = { + timestamp: Date.now(), + responseTime: 0, + folders: [], + files: [], + nextPageToken: "nextpageToken", + readmeExists: true, + bannerExists: true, + passwordExists: false, +}; +const mockFilesProtected: APIFilesResponse = { + timestamp: Date.now(), + responseTime: 0, + folders: [], + files: [], + nextPageToken: "nextpageToken", + readmeExists: true, + bannerExists: true, + passwordExists: true, +}; + +for (let i = 0; i < 10; i++) { + const folder: IGDriveFiles = { + mimeType: "application/vnd.google-apps.folder", + encryptedId: `encryptedId${i + 1}`, + name: `folder${i + 1}`, + trashed: false, + modifiedTime: new Date().toISOString(), + }; + const file: IGDriveFiles = { + mimeType: "application/vnd.google-apps.file", + encryptedId: `encryptedId${i + 1}`, + name: `file${i + 1}`, + trashed: false, + modifiedTime: new Date().toISOString(), + fileExtension: "txt", + encryptedWebContentLink: "encryptedWebContentLink", + size: Math.floor(Math.random() * 1000000000), + thumbnailLink: "/og.png", + imageMediaMetadata: + Math.random() > 0.5 + ? { + width: 1920, + height: 1080, + rotation: 0, + } + : null, + videoMediaMetadata: + Math.random() > 0.5 + ? { + width: 1920, + height: 1080, + durationMillis: 100000, + } + : null, + }; + + mockFiles.folders.push(folder); + mockFiles.files.push(file); + + folder.name = `folder${i + 1}protected`; + file.name = `file${i + 1}protected`; + mockFilesProtected.folders.push(folder); + mockFilesProtected.files.push(file); +} + +export { mockFiles, mockFilesProtected }; diff --git a/next.config.js b/next.config.js index a843cbe..8851780 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,34 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, -} -module.exports = nextConfig +const withMDX = require("@next/mdx")({ + extension: /\.mdx?$/, + options: { + // If you use remark-gfm, you'll need to use next.config.mjs + // as the package is ESM only + // https://github.com/remarkjs/remark-gfm#install + remarkPlugins: [], + rehypePlugins: [], + // If you use `MDXProvider`, uncomment the following line. + providerImportSource: "@mdx-js/react", + }, +}); + +const nextConfig = { + pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], + reactStrictMode: true, + async headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "Cache-Control", + value: "max-age=300, s-maxage=300, stale-while-revalidate, public", + }, + ], + }, + ]; + }, +}; + +module.exports = withMDX(nextConfig); diff --git a/package.json b/package.json index 2fee51f..5e77880 100644 --- a/package.json +++ b/package.json @@ -3,62 +3,62 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 5000", + "dev": "next dev -p 3000", "build": "next build", "start": "next start", "lint": "next lint", - "cypress": "cypress open" + "cypress": "cypress open", + "pretty": "prettier --write ." }, "dependencies": { - "@react-three/drei": "^9.66.1", - "@react-three/fiber": "^8.13.0", - "@types/react": "18.0.35", - "@types/react-dom": "18.0.11", - "@upstash/redis": "^1.20.4", - "@vercel/og": "^0.5.4", - "autoprefixer": "10.4.14", + "@cyntler/react-doc-viewer": "^1.13.0", + "@iconify/react": "^4.1.1", + "@mdx-js/loader": "^2.3.0", + "@mdx-js/react": "^2.3.0", + "@next/mdx": "^13.4.19", + "@popperjs/core": "^2.11.8", "axios": "^1.3.5", - "cors": "^2.8.5", "googleapis": "^118.0.0", "jsonwebtoken": "^9.0.0", - "mime-types": "^2.1.35", - "next": "13.3.0", - "next-seo": "^6.0.0", - "nextjs-progressbar": "^0.0.16", - "postcss": "8.4.22", - "react": "18.2.0", - "react-dom": "18.2.0", + "jszip": "^3.10.1", + "next": "^13.4.13", + "next-seo": "^6.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-h5-audio-player": "^3.8.6", - "react-icons": "^4.8.0", - "react-loading": "^2.0.3", + "react-is": "^18.2.0", "react-markdown": "^8.0.7", - "react-player": "^2.12.0", - "react-toastify": "^9.1.2", - "rehype-katex": "^6.0.2", - "rehype-prism-plus": "^1.5.1", - "rehype-raw": "^6.1.1", - "rehype-sanitize": "^5.0.1", - "remark-breaks": "^3.0.2", + "react-player": "^2.13.0", + "react-popper": "^2.3.0", + "react-toastify": "^9.1.3", + "rehype-katex": "^6.0.3", + "rehype-prism-plus": "^1.6.3", + "rehype-raw": "6.1.1", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", "remark-slug": "^7.0.1", - "remark-toc": "^8.0.1", - "swr": "^2.1.3", - "tailwindcss": "3.3.1", - "three": "^0.152.0" + "remark-toc": "^8.0.1" }, "devDependencies": { + "@faker-js/faker": "^8.0.2", "@tailwindcss/typography": "^0.5.9", + "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/cors": "^2.8.13", "@types/jsonwebtoken": "^9.0.1", "@types/mime-types": "^2.1.1", "@types/node": "18.15.11", + "@types/react": "18.0.35", + "@types/react-dom": "18.0.11", "@types/three": "^0.150.2", - "cypress": "^12.10.0", + "autoprefixer": "10.4.14", + "encoding": "^0.1.13", "eslint": "8.38.0", - "eslint-config-next": "13.3.0", + "eslint-config-next": "^13.4.3", + "postcss": "8.4.22", "prettier": "^2.8.7", "prettier-plugin-tailwindcss": "^0.2.7", + "tailwind-merge": "^1.14.0", + "tailwindcss": "3.3.1", "typescript": "5.0.4" } } diff --git a/public/flaticon.svg b/public/flaticon.svg new file mode 100644 index 0000000..aed7d74 --- /dev/null +++ b/public/flaticon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/404.png b/public/images/404.png new file mode 100644 index 0000000..0cf89fa Binary files /dev/null and b/public/images/404.png differ diff --git a/public/images/password.png b/public/images/password.png new file mode 100644 index 0000000..0ee8346 Binary files /dev/null and b/public/images/password.png differ diff --git a/public/setup/FinalStep.md b/public/setup/FinalStep.md deleted file mode 100644 index 405dcec..0000000 --- a/public/setup/FinalStep.md +++ /dev/null @@ -1,26 +0,0 @@ -To finishing the setup, you need to do the following steps: - -## API Config -The API Config file are located at `src/config/api.ts`. You need to fill in the following information: -```js -module.exports = { - client_id: "lorem", - client_secret: "ipsum", - refresh_token: "dolor", -} -``` - -## Environment Config -The environment variables are differ for each hosting service. If you are using Vercel, you can add the environment variables on the project settings page. -```dotenv -ENCRYPTION_KEY="Encryption" -``` - -**NOTE:** Make sure to redeploy the site after adding the environment variables. - -## Customizing the Site -The site can be customized by editing the `src/config/site.ts` file. You can change the site title, description, and other information. -All the explanation also included in the file. - - -And that's it, you're done with the setup. You can now start using the site. \ No newline at end of file diff --git a/public/setup/GoogleCloudStep.md b/public/setup/GoogleCloudStep.md deleted file mode 100644 index 5425a23..0000000 --- a/public/setup/GoogleCloudStep.md +++ /dev/null @@ -1,47 +0,0 @@ -# Google Cloud Setup - -Table of Contents: -- [Making a Google Cloud Project and enabling Google Drive API](#making-a-google-cloud-project-and-enabling-google-drive-api) -- [Creating Oauth Consent Screen](#creating-oauth-consent-screen) -- [Creating Oauth Client ID](#creating-oauth-client-id) -- [Getting Refresh Token](#getting-refresh-token) - -## Making a Google Cloud Project and enabling Google Drive API -1. Open [Google Cloud Console](https://console.cloud.google.com/). -2. Click on the project selector dropdown in the top left corner and select or create a new project. -![google-cloud-1.png](../images/setup/google-cloud-1.png) -3. Activate the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) for your project. -![google-cloud-3.png](../images/setup/google-cloud-3.png) -4. After activating the API, you will be redirected to the API overview page. For creating Oauth Client ID, you need to create Oauth consent first, you can do this by clicking on the "Oauth Consent Screen" menu. - -## Creating Oauth Consent Screen -1. On the Oauth consent screen page, select "External" as the user type and click on the "Create" button. -![google-cloud-5.png](../images/setup/google-cloud-5.png) -2. On the next page, fill in the required information and click on the "Save" button. -(In my case I'm only filling `App name`, `User support email`, and `Developer contact information`.) -3. On the **Scopes** Page, select the following scopes and click on the "Save" button, then click on the "Continue" button. -![google-cloud-6.png](../images/setup/google-cloud-6.png) -4. On the **Test Users** Page, click on the "Add Users" button and add your email address or any other email address that you want to use. Then click on the "Save and Continue" button. -5. And you're done with the Oauth consent screen. Now you can create the Oauth Client ID by clicking on the "Credentials" menu. -**NOTE: We don't need to publish the app, since we're only using it for our own purpose.** - -## Creating Oauth Client ID -1. On the **Credentials** page, click on the "Create Credentials" button and select "Oauth Client ID". -![google-cloud-4.png](../images/setup/google-cloud-4.png) -2. On the next page, select "Web Application" as the application type, and fill in the required information. -3. In the **Authorized redirect URIs** section, add the following redirect URI: `https://developers.google.com/oauthplayground`, then click on the "Create" button. -4. After creating the Oauth Client ID, you will be redirected to the **Credentials** page. Then copy the Client ID, and Client Secret. (You will need them later on the bottom of this page, and to fetch refresh token.) - -## Getting Refresh Token -1. Open [Google OAuth Playground](https://developers.google.com/oauthplayground/). -2. Click on the gear icon on the upper right corner and check the box next to "Use your own OAuth credentials" and paste the Client ID and Client Secret that you copied earlier. -![google-cloud-7.png](../images/setup/google-cloud-7.png) -3. Select the following scopes and click on the "Authorize APIs" button. -![google-cloud-8.png](../images/setup/google-cloud-8.png) -4. Google will ask you to log in to your Google account, you need to log in using the account that specified on Test User (Creating Oauth Consent Screen - step 4). -5. It will show you a warning that the app is not verified, simply click on "Continue" button, and it will redirect you back to the Playground. -![google-cloud-9.png](../images/setup/google-cloud-9.png) -6. Click on the "Exchange authorization code for tokens" button, and it will show you the refresh token. Copy the refresh token and paste it on the bottom of this page. -![google-cloud-10.png](../images/setup/google-cloud-10.png) -7. You can close the Playground tab now. -8. You're done with the Google Cloud Setup. \ No newline at end of file diff --git a/public/setup/googleCloud.md b/public/setup/googleCloud.md new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/download/[encryptedId]/[fileName]/route.ts b/src/app/api/download/[encryptedId]/[fileName]/route.ts new file mode 100644 index 0000000..1837237 --- /dev/null +++ b/src/app/api/download/[encryptedId]/[fileName]/route.ts @@ -0,0 +1,94 @@ +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", + }); + const _fileContent = gdrive.files.get( + { + fileId: decryptData(data.id), + 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/experimental/validatePath/[...path]/route.ts b/src/app/api/experimental/validatePath/[...path]/route.ts new file mode 100644 index 0000000..99b7139 --- /dev/null +++ b/src/app/api/experimental/validatePath/[...path]/route.ts @@ -0,0 +1,213 @@ +import gIndexConfig from "config"; +import { NextRequest, NextResponse } from "next/server"; + +import { + decryptData, + encryptData, +} from "utils/encryptionHelper/hash"; +import ExtendedError from "utils/extendedError"; +import gdrive from "utils/gdriveInstance"; +import { checkPathPassword } from "utils/passwordHelper"; + +import { + APIValidateResponse, + ErrorResponse, +} from "types/api/response"; +import { Constant } from "types/constant"; + +type FilePath = { + name: string; + encryptedId: string; + mimeType: string; +}; + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } }, +) { + const reqStart = Date.now(); + const pathArray = params.path; + + try { + const fetchRootId = gdrive.files.get({ + fileId: gIndexConfig.apiConfig.rootFolder, + fields: "id", + }); + const fetchPathId = pathArray.map(async (path) => { + const query = ["trashed = false", `name = '${path}'`]; + const fetchFolderContents = await gdrive.files.list({ + q: `${query.join(" and ")}`, + fields: "files(id, name, mimeType, parents)", + }); + + if (!fetchFolderContents.data.files?.length) { + throw new Error(`Path "${path}" is not found`); + } + + return { + path, + data: fetchFolderContents.data.files.map( + (file) => ({ + id: file.id, + parents: file.parents?.[0], + mimeType: file.mimeType, + }), + ), + }; + }); + + const [rootId, pathId] = await Promise.all([ + fetchRootId, + Promise.all(fetchPathId), + ]); + + const selectedPath: string[] = []; + const mappedPath: FilePath[] = []; + + // Check path validity + pathId.forEach((path, index) => { + let checkPath = + index === 0 + ? path.data.find( + (file) => file.parents === rootId.data.id, + ) + : path.data.find( + (file) => + file.parents === selectedPath[index - 1], + ); + if (!checkPath) + throw new ExtendedError( + "Path not found", + 404, + `Can't find path "${path.path}". Please check your path again.`, + ); + selectedPath.push(checkPath.id as string); + mappedPath.push({ + name: path.path, + encryptedId: encryptData(checkPath.id as string), + mimeType: checkPath.mimeType as string, + }); + }); + + return NextResponse.json(mappedPath, { + status: 200, + }); + + // Check for protected folder + const fetchProtectedFolder = mappedPath.map( + async (path) => { + if ( + path.mimeType !== + "application/vnd.google-apps.folder" + ) + return; + + const query = [ + "trashed = false", + `'${decryptData(path.encryptedId)}' in parents`, + `name = '${gIndexConfig.apiConfig.specialFile.password}'`, + ]; + + const fetchFolderContents = await gdrive.files.list( + { + q: `${query.join(" and ")}`, + fields: "files(id, name, mimeType, parents)", + }, + ); + if (!fetchFolderContents.data.files?.length) return; + + return { + path: path.name, + passwordId: fetchFolderContents.data.files[0].id, + }; + }, + ); + let protectedFolder = await Promise.all( + fetchProtectedFolder, + ); + console.log(protectedFolder); + protectedFolder = protectedFolder.filter( + (item) => item !== undefined, + ); + const nearestFolderPassword = + protectedFolder[protectedFolder.length - 1] ?? null; + + console.log( + "After nearestFolderPassword", + nearestFolderPassword, + ); + + if ( + nearestFolderPassword && + nearestFolderPassword.passwordId + ) { + const fetchPassword = await gdrive.files.get( + { + fileId: nearestFolderPassword.passwordId, + alt: "media", + }, + { + responseType: "text", + }, + ); + + const userPassword = + request.cookies.get(Constant.cookies_SitePassword) + ?.value ?? null; + if (!userPassword) { + throw new ExtendedError( + "Password required", + 401, + `You need to enter password to access this file / folder`, + ); + } + + const nearestPathIndex = mappedPath.findIndex( + (path) => path.name === nearestFolderPassword.path, + ); + const currentPath = + "/" + + mappedPath + .slice(0, nearestPathIndex + 1) + .map((path) => path.name) + .join("/"); + + const validPassword = checkPathPassword( + currentPath, + userPassword, + fetchPassword.data as string, + ); + if (!validPassword) { + throw new ExtendedError( + "Invalid password", + 401, + `The password you entered is invalid`, + ); + } + } + + const payload: APIValidateResponse = { + timestamp: Date.now(), + responseTime: Date.now() - reqStart, + path: mappedPath.map((path) => path.name).join("/"), + valid: true, + }; + + 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/files/[encryptedId]/route.ts b/src/app/api/files/[encryptedId]/route.ts new file mode 100644 index 0000000..c270e60 --- /dev/null +++ b/src/app/api/files/[encryptedId]/route.ts @@ -0,0 +1,155 @@ +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 gdrive from "utils/gdriveInstance"; + +import { IGDriveFiles } from "types/api/files"; +import { APIFileResponse, APIFilesResponse, ErrorResponse } from "types/api/response"; + +export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) { + const reqStart = Date.now(); + try { + const id = decryptData(params.encryptedId); + if (id === gIndexConfig.apiConfig.rootFolder) + return NextResponse.redirect(`${process.env.NEXT_PUBLIC_VERCEL_URL}/api/files`, { status: 301 }); + + const file = await gdrive.files.get({ + fileId: id, + fields: gIndexConfig.apiConfig.defaultField, + }); + + if (!file || file.data.trashed) throw new ExtendedError("File not found", 404, "Can't find requested file."); + if (file.data.mimeType !== "application/vnd.google-apps.folder") { + const res: APIFileResponse = { + timestamp: Date.now(), + responseTime: Date.now() - reqStart, + file: { + mimeType: file.data.mimeType as string, + fileExtension: file.data.fileExtension as string, + encryptedId: params.encryptedId, + name: file.data.name as string, + trashed: file.data.trashed as boolean, + modifiedTime: file.data.modifiedTime as string, + encryptedWebContentLink: encryptData(file.data.webContentLink as string), + size: Number(file.data.size ?? 0), + thumbnailLink: file.data.thumbnailLink as string, + imageMediaMetadata: file.data.imageMediaMetadata + ? { + width: Number(file.data.imageMediaMetadata.width), + height: Number(file.data.imageMediaMetadata.height), + rotation: Number(file.data.imageMediaMetadata.rotation), + } + : undefined, + videoMediaMetadata: file.data.videoMediaMetadata + ? { + width: Number(file.data.videoMediaMetadata.width), + height: Number(file.data.videoMediaMetadata.height), + durationMillis: Number(file.data.videoMediaMetadata.durationMillis), + } + : undefined, + }, + }; + + return NextResponse.json(res, { + status: 200, + headers: { + "Cache-Control": gIndexConfig.cacheControl, + }, + }); + } + + const { pageToken } = getSearchParams(request.url, ["pageToken"]); + + const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and "); + const query: string[] = [...gIndexConfig.apiConfig.defaultQuery, `'${id}' in parents`, `${filterName}`]; + const fetchFolderContents = await gdrive.files.list({ + q: query.join(" and "), + fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, + orderBy: gIndexConfig.apiConfig.defaultOrder, + pageSize: gIndexConfig.apiConfig.itemsPerPage, + pageToken: pageToken || undefined, + }); + + const folders: IGDriveFiles[] = + fetchFolderContents.data.files + ?.filter((item) => item.mimeType === "application/vnd.google-apps.folder") + .map((item) => ({ + mimeType: item.mimeType as string, + encryptedId: encryptData(item.id as string), + name: item.name as string, + trashed: item.trashed as boolean, + modifiedTime: item.modifiedTime as string, + })) ?? []; + const files: IGDriveFiles[] = + fetchFolderContents.data.files + ?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder") + .map((item) => ({ + mimeType: item.mimeType as string, + fileExtension: item.fileExtension as string, + encryptedId: encryptData(item.id as string), + name: item.name as string, + trashed: item.trashed as boolean, + modifiedTime: item.modifiedTime as string, + encryptedWebContentLink: encryptData(item.webContentLink as string), + size: Number(item.size ?? 0), + thumbnailLink: item.thumbnailLink as string, + imageMediaMetadata: item.imageMediaMetadata + ? { + width: Number(item.imageMediaMetadata.width ?? 0), + height: Number(item.imageMediaMetadata.height ?? 0), + rotation: Number(item.imageMediaMetadata.rotation ?? 0), + } + : undefined, + videoMediaMetadata: item.videoMediaMetadata + ? { + width: Number(item.videoMediaMetadata.width ?? 0), + height: Number(item.videoMediaMetadata.height ?? 0), + durationMillis: Number(item.videoMediaMetadata.durationMillis ?? 0), + } + : undefined, + })) ?? []; + const readmeExists = + fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.readme) ?? false; + const bannerExists = + fetchFolderContents.data.files?.some( + (item) => + item.name?.startsWith(gIndexConfig.apiConfig.specialFile.banner) && item.mimeType?.startsWith("image/"), + ) ?? false; + const passwordExists = + fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.password) ?? + false; + + const res: APIFilesResponse = { + timestamp: Date.now(), + responseTime: Date.now() - reqStart, + folders, + files, + nextPageToken: fetchFolderContents.data.nextPageToken || undefined, + readmeExists, + bannerExists, + passwordExists, + }; + return NextResponse.json(res, { + 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/files/route.ts b/src/app/api/files/route.ts new file mode 100644 index 0000000..4836b91 --- /dev/null +++ b/src/app/api/files/route.ts @@ -0,0 +1,150 @@ +import gIndexConfig from "config"; +import { NextRequest, NextResponse } from "next/server"; + +import getSearchParams from "utils/apiHelper/getSearchParams"; +import privateIndexProtection from "utils/apiHelper/privateIndexProtection"; +import { encryptData } from "utils/encryptionHelper/hash"; +import gdrive from "utils/gdriveInstance"; + +import { IGDriveFiles } from "types/api/files"; +import { + APIFilesResponse, + ErrorResponse, +} from "types/api/response"; + +export async function GET(request: NextRequest) { + const reqStart = Date.now(); + try { + await privateIndexProtection(request); + const { pageToken } = getSearchParams(request.url, [ + "pageToken", + ]); + + const query: string[] = [ + ...gIndexConfig.apiConfig.defaultQuery, + `'${gIndexConfig.apiConfig.rootFolder}' in parents`, + ]; + const fetchFolderContents = await gdrive.files.list({ + q: query.join(" and "), + fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, + orderBy: gIndexConfig.apiConfig.defaultOrder, + pageSize: gIndexConfig.apiConfig.itemsPerPage, + pageToken: pageToken || undefined, + }); + + const folders: IGDriveFiles[] = + fetchFolderContents.data.files + ?.filter( + (item) => + item.mimeType === + "application/vnd.google-apps.folder", + ) + .map((item) => ({ + mimeType: item.mimeType as string, + encryptedId: encryptData(item.id as string), + name: item.name as string, + trashed: item.trashed as boolean, + modifiedTime: item.modifiedTime as string, + })) ?? []; + const files: IGDriveFiles[] = + fetchFolderContents.data.files + ?.filter( + (item) => + item.mimeType !== + "application/vnd.google-apps.folder" && + !gIndexConfig.apiConfig.hiddenFiles.includes( + item.name as string, + ), + ) + .map((item) => ({ + mimeType: item.mimeType as string, + fileExtension: item.fileExtension as string, + encryptedId: encryptData(item.id as string), + name: item.name as string, + trashed: item.trashed as boolean, + modifiedTime: item.modifiedTime as string, + encryptedWebContentLink: encryptData( + item.webContentLink as string, + ), + size: Number(item.size ?? 0), + thumbnailLink: item.thumbnailLink as string, + imageMediaMetadata: item.imageMediaMetadata + ? { + width: Number( + item.imageMediaMetadata.width ?? 0, + ), + height: Number( + item.imageMediaMetadata.height ?? 0, + ), + rotation: Number( + item.imageMediaMetadata.rotation ?? 0, + ), + } + : undefined, + videoMediaMetadata: item.videoMediaMetadata + ? { + width: Number( + item.videoMediaMetadata.width ?? 0, + ), + height: Number( + item.videoMediaMetadata.height ?? 0, + ), + durationMillis: Number( + item.videoMediaMetadata.durationMillis ?? + 0, + ), + } + : undefined, + })) ?? []; + const readmeExists = + fetchFolderContents.data.files?.some( + (item) => + item.name === + gIndexConfig.apiConfig.specialFile.readme, + ) ?? false; + const bannerExists = + fetchFolderContents.data.files?.some( + (item) => + item.name?.startsWith( + gIndexConfig.apiConfig.specialFile.banner, + ) && item.mimeType?.startsWith("image/"), + ) ?? false; + const passwordExists = + fetchFolderContents.data.files?.some( + (item) => + item.name === + gIndexConfig.apiConfig.specialFile.password, + ) ?? false; + + const res: APIFilesResponse = { + timestamp: Date.now(), + responseTime: Date.now() - reqStart, + folders, + files, + nextPageToken: + fetchFolderContents.data.nextPageToken || undefined, + readmeExists, + bannerExists, + passwordExists, + }; + return NextResponse.json(res, { + 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/getData/route.ts b/src/app/api/getData/route.ts new file mode 100644 index 0000000..f88c82f --- /dev/null +++ b/src/app/api/getData/route.ts @@ -0,0 +1,190 @@ +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 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, + }); + 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 gdrive.files.list({ + q: query.join(" and "), + fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`, + orderBy: gIndexConfig.apiConfig.defaultOrder, + pageSize: gIndexConfig.apiConfig.itemsPerPage, + pageToken: pageToken ?? undefined, + }); + + 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 new file mode 100644 index 0000000..91930e1 --- /dev/null +++ b/src/app/api/getPassword/route.ts @@ -0,0 +1,129 @@ +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 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 gdrive.files.list({ + 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", + }, + { 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 new file mode 100644 index 0000000..fd7f307 --- /dev/null +++ b/src/app/api/getReadme/route.ts @@ -0,0 +1,79 @@ +import gIndexConfig from "config"; +import { NextRequest, NextResponse } from "next/server"; + +import getSearchParams from "utils/apiHelper/getSearchParams"; +import { decryptData } from "utils/encryptionHelper/hash"; +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 gdrive.files.list({ + 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/password/add/route.ts b/src/app/api/password/add/route.ts new file mode 100644 index 0000000..7d917b0 --- /dev/null +++ b/src/app/api/password/add/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; + +import getSearchParams from "utils/apiHelper/getSearchParams"; +import ExtendedError from "utils/extendedError"; +import { addNewPassword } from "utils/passwordHelper"; + +import { ErrorResponse } from "types/api/response"; +import { Constant } from "types/constant"; + +export async function GET(request: NextRequest) { + const reqStart = Date.now(); + + try { + const { path, password } = getSearchParams( + request.url, + ["path", "password"], + ); + if (!path || !password) + throw new ExtendedError( + "Missing parameters", + 400, + "Path or password is missing", + ); + + const cookies = request.cookies.get( + Constant.cookies_SitePassword, + ); + if (!cookies) + throw new ExtendedError( + "No cookies found", + 400, + "Site password not found", + ); + const newPassword = addNewPassword( + path!, + password!, + cookies.value, + true, + ); + + return NextResponse.json( + { + newPassword: { + path, + password, + }, + }, + { + status: 200, + headers: { + "Set-Cookie": newPassword as string, + }, + }, + ); + } 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/password/route.ts b/src/app/api/password/route.ts new file mode 100644 index 0000000..c579ce2 --- /dev/null +++ b/src/app/api/password/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { decryptData } from "utils/encryptionHelper/hash"; +import ExtendedError from "utils/extendedError"; + +import { ErrorResponse } from "types/api/response"; +import { Constant } from "types/constant"; + +export async function GET(request: NextRequest) { + const reqStart = Date.now(); + + try { + const cookies = request.cookies.get( + Constant.cookies_SitePassword, + ); + if (!cookies) + throw new ExtendedError( + "No cookies found", + 400, + "Site password not found", + ); + const rawToken = decryptData(cookies.value); + const token = JSON.parse(rawToken); + + return NextResponse.json( + { + password: token, + }, + { + 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/search/redirect/[encryptedId]/route.ts b/src/app/api/search/redirect/[encryptedId]/route.ts new file mode 100644 index 0000000..354f5a5 --- /dev/null +++ b/src/app/api/search/redirect/[encryptedId]/route.ts @@ -0,0 +1,76 @@ +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"); + + const path: string[] = []; + let lastId = id; + const fileContent = await gdrive.files.get({ + fileId: lastId, + fields: "id, name, mimeType, parents", + }); + 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", + }); + 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("/"), process.env.NEXT_PUBLIC_VERCEL_URL); + return NextResponse.redirect(redirectURL.toString(), { + status: 302, + 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/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..b6c268f --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,65 @@ +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 gdrive from "utils/gdriveInstance"; + +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 gdrive.files.list({ + 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/components/APIFeedback/Empty.tsx b/src/components/APIFeedback/Empty.tsx deleted file mode 100644 index d0a24bd..0000000 --- a/src/components/APIFeedback/Empty.tsx +++ /dev/null @@ -1,13 +0,0 @@ -type Props = { - message?: string; -}; - -export default function EmptyFeedback({ message }: Props) { - return ( -
-
- {message || "Folder is empty"} -
-
- ); -} diff --git a/src/components/APIFeedback/Error.tsx b/src/components/APIFeedback/Error.tsx deleted file mode 100644 index 9eeb85b..0000000 --- a/src/components/APIFeedback/Error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { MdWarning } from "react-icons/md"; - -type Props = { - message?: string; -}; - -export default function ErrorFeedback({ message }: Props) { - const errorMessage: string = `An error occurred while processing your request. -Please check the following details and try again:`; - - return ( -
-
- -

{errorMessage}

-
- -
-
- Error details: -
-          
-            {message || "Internal server error"}
-          
-        
-
-
- ); -} diff --git a/src/components/APIFeedback/Loading.tsx b/src/components/APIFeedback/Loading.tsx deleted file mode 100644 index 21460d4..0000000 --- a/src/components/APIFeedback/Loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ReactLoading from "react-loading"; - -type Props = { - message?: string; -}; - -export default function LoadingFeedback({ message }: Props) { - return ( -
-
- - {message || "Loading..."} -
-
- ); -} diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx new file mode 100644 index 0000000..d167df1 --- /dev/null +++ b/src/components/Alert/index.tsx @@ -0,0 +1,53 @@ +import { Icon, IconifyIcon } from "@iconify/react"; +import { twMerge } from "tailwind-merge"; + +interface AlertProps { + variant?: "success" | "error" | "warning" | "info"; + icon?: string | IconifyIcon; + title?: string; + message: string; + className?: string; +} +export default function Alert(props: AlertProps) { + const { + variant = "info", + icon, + title, + message, + className, + } = props; + return ( +
+ {(icon || title) && ( +
+ {icon && ( + + )} + {title && ( + + {title} + + )} +
+ )} +

{message}

+
+ ); +} diff --git a/src/components/Breadcrumb/index.tsx b/src/components/Breadcrumb/index.tsx index 2fc390d..06437d4 100644 --- a/src/components/Breadcrumb/index.tsx +++ b/src/components/Breadcrumb/index.tsx @@ -1,64 +1,165 @@ -import { BreadCrumbsResponse, TFileParent } from "types/googleapis"; +import { Icon, IconifyIcon } from "@iconify/react"; import Link from "next/link"; -import { Fragment } from "react"; -import { MdHome } from "react-icons/md"; -import siteConfig from "config/site.config"; +import { useRouter } from "next/router"; +import { Fragment, useEffect, useState } from "react"; -type Props = { - data: BreadCrumbsResponse | undefined; - isLoading: boolean; -}; +import ButtonIcon from "components/ButtonIcon"; +import ClickAway from "components/ClickAway"; -export default function Breadcrumb({ data, isLoading }: Props) { - const reverseBreadcrumbs = data?.breadcrumbs?.slice().reverse(); +interface BreadcrumbProps { + paths: Record<"label" | "path", string>[]; + separator?: string | IconifyIcon; + maxPath?: number; +} + +export default function Breadcrumb(props: BreadcrumbProps) { + const router = useRouter(); + const { + paths, + separator = "ion:chevron-forward", + maxPath = 2, + } = props; + const [breadcrumbPaths, setBreadcrumbPaths] = + useState[]>(paths); + const [isPathSliced, setIsPathSliced] = + useState(false); + const [hiddenPaths, setHiddenPaths] = useState< + Record<"label" | "path", string>[] + >([]); + const [menuAnchor, setMenuAnchor] = + useState(null); + + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + if (paths.length > maxPath) { + const newPaths = paths.slice( + paths.length - maxPath, + paths.length, + ); + const hiddenPaths = paths.slice( + 0, + paths.length - maxPath, + ); + setBreadcrumbPaths(newPaths); + setIsPathSliced(true); + setHiddenPaths(hiddenPaths); + } else { + setBreadcrumbPaths(paths); + setIsPathSliced(false); + } + }, [paths, maxPath]); return ( <> - {isLoading ? ( -
- ) : ( -
- {/* Home */} - - - Root - - {data?.isLimitReached && ( - - {siteConfig.breadcrumb.limiter} - ... - - )} -
- {reverseBreadcrumbs?.map((parent: TFileParent, index: number) => ( - - {siteConfig.breadcrumb.limiter} - - - {parent.name} - - - +
+ + + {/* */} + + {isPathSliced && ( + <> + + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(!menuOpen); + }} + /> + {/* ... */} + + )} + {breadcrumbPaths.map((path, index) => ( + + + + {decodeURIComponent(path.label)} + + + ))} +
+ + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(false); + }} + className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${ + menuOpen + ? "opacity-100 pointer-events-auto" + : "opacity-0 pointer-events-none" + }`} + > +
+
+ {hiddenPaths.map((path, index) => ( + + {decodeURIComponent(path.label)} + ))}
- )} +
); } diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx new file mode 100644 index 0000000..8f77660 --- /dev/null +++ b/src/components/Button/index.tsx @@ -0,0 +1,121 @@ +import { Icon, IconifyIcon } from "@iconify/react"; +import { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +interface ButtonProps + extends React.ButtonHTMLAttributes { + fullWidth?: boolean; + variant?: + | "primary" + | "accent" + | "success" + | "danger" + | "warning" + | "info" + | "transparent"; + size?: "small" | "medium" | "large"; + pill?: boolean; + square?: boolean; + rounded?: "none" | "small" | "medium" | "large"; + startIcon?: string | IconifyIcon; + endIcon?: string | IconifyIcon; + children: ReactNode; + loading?: boolean; + className?: string; +} + +export default function Button(props: ButtonProps) { + const { + fullWidth = false, + variant = "primary", + size = "medium", + pill = false, + square = false, + rounded = "none", + loading = false, + startIcon, + endIcon, + children, + className, + ...rest + } = props; + + return ( + + ); +} diff --git a/src/components/ButtonGroup/index.tsx b/src/components/ButtonGroup/index.tsx new file mode 100644 index 0000000..cdfec8d --- /dev/null +++ b/src/components/ButtonGroup/index.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +interface ButtonGroupProps { + children: ReactNode; + fullWidth?: boolean; + rounded?: "none" | "small" | "medium" | "large"; + className?: string; +} + +export default function ButtonGroup( + props: ButtonGroupProps, +) { + const { + children, + fullWidth = false, + rounded = "large", + className, + } = props; + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ButtonIcon/index.tsx b/src/components/ButtonIcon/index.tsx new file mode 100644 index 0000000..b1fbd2f --- /dev/null +++ b/src/components/ButtonIcon/index.tsx @@ -0,0 +1,58 @@ +import { Icon, IconifyIcon } from "@iconify/react"; +import { ForwardRefRenderFunction, forwardRef } from "react"; +import { twMerge } from "tailwind-merge"; + +interface ButtonIconProps extends React.ButtonHTMLAttributes { + icon: string | IconifyIcon; + size?: "small" | "medium" | "large"; + variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent"; + className?: string; +} + +const ButtonIcon: ForwardRefRenderFunction = (props, ref) => { + const { icon, size = "medium", variant = "primary", className, ...rest } = props; + let iconSize = 24; + switch (size) { + case "small": + iconSize = 16; + break; + case "medium": + iconSize = 24; + break; + case "large": + iconSize = 32; + } + + return ( + + ); +}; + +export default forwardRef(ButtonIcon); diff --git a/src/components/ClickAway/index.tsx b/src/components/ClickAway/index.tsx new file mode 100644 index 0000000..d0785ae --- /dev/null +++ b/src/components/ClickAway/index.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from "react"; +import { twMerge } from "tailwind-merge"; + +interface ClickAwayProps extends React.HTMLAttributes { + children: ReactNode; + open: boolean; + onClickAway: (e: any) => void; + className?: string; +} +export default function ClickAway(props: ClickAwayProps) { + const { children, open, onClickAway, className, ...rest } = props; + + useEffect(() => { + const body = document.querySelector("body"); + const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight; + const isMobile = window.matchMedia("(max-width: 768px)").matches; + if (open && isScrollbarRendered) { + body?.style.setProperty("overflow", "hidden"); + if (!isMobile) body?.style.setProperty("padding-right", "4px"); + } else { + body?.style.removeProperty("overflow"); + if (!isMobile) body?.style.removeProperty("padding-right"); + } + }, [open]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/File/Grid.tsx b/src/components/File/Grid.tsx deleted file mode 100644 index 4750ddf..0000000 --- a/src/components/File/Grid.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { formatDuration } from "utils/formatHelper"; -import { drive_v3 } from "googleapis"; -import Link from "next/link"; -import { MdPlayCircleFilled } from "react-icons/md"; -import { getFileIcon, getFileType } from "utils/mimeTypesHelper"; -import { BsFolderFill } from "react-icons/bs"; -import { createFileId } from "utils/driveHelper"; -import siteConfig from "config/site.config"; - -type Props = { - data: drive_v3.Schema$File; -}; - -export default function GridFile({ data }: Props) { - const isFolder = data.mimeType === "application/vnd.google-apps.folder"; - // const Icon = getMimeIcon(data.mimeType as string); - const Icon = isFolder - ? BsFolderFill - : getFileIcon(data.fileExtension as string, data.mimeType as string); - const fileType = getFileType( - data.fileExtension as string, - data.mimeType as string, - ); - const allowThumbnail = - siteConfig.files.allowThumbnailFileType.includes(fileType); - - return ( - - {/* Thumbnail */} -
- {data.thumbnailLink && allowThumbnail ? ( -
- {data.name - {data.videoMediaMetadata && ( -
- - - {formatDuration(data.videoMediaMetadata.durationMillis || 0)} - -
- )} -
- ) : ( - - )} -
- - {/* File name */} -
- {siteConfig.files.showFileNameIcon && ( - <> - {data.iconLink ? ( - {"icon"} - ) : ( - - )} - - )} - - {data.name} - -
- - ); -} diff --git a/src/components/File/GridItem/index.tsx b/src/components/File/GridItem/index.tsx new file mode 100644 index 0000000..138a845 --- /dev/null +++ b/src/components/File/GridItem/index.tsx @@ -0,0 +1,233 @@ +import { Icon } from "@iconify/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; + +import ButtonIcon from "components/ButtonIcon"; +import ClickAway from "components/ClickAway"; +import Tooltip from "components/Tooltip"; + +import bytesToReadable from "utils/bytesFormat"; +import { stripExtension } from "utils/filesHelper"; +import { generatedDownloadLink } from "utils/generateDownloadLink"; +import { getPreviewIcon } from "utils/previewHelper"; +import { createDownloadToken } from "utils/tokenHelper"; + +import { IGDriveFiles } from "types/api/files"; + +interface ListItemProps { + file: IGDriveFiles; + isProtected: boolean; +} +export default function ListItem(props: ListItemProps) { + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(); + + const [fileInfo, setFileInfo] = useState([]); + const [extensionInfo, setExtensionInfo] = useState([]); + const [filePath, setFilePath] = useState(""); + + useEffect(() => { + const currentPath = router.asPath.slice(1); + const filePath = props.file.name; + const path = [currentPath, encodeURIComponent(filePath)].join("/"); + + // If start with /, remove it + if (path.startsWith("/")) { + setFilePath(path.slice(1)); + } else { + setFilePath(path); + } + }, [router.asPath, props.file.name]); + + useEffect(() => { + const info = []; + const extInfo = []; + props.file.modifiedTime && + info.push( + new Date(props.file.modifiedTime).toLocaleDateString("default", { + year: "numeric", + month: "short", + day: "numeric", + }), + ); + props.file.size && info.push(bytesToReadable(props.file.size)); + props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder"); + props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase()); + setExtensionInfo(extInfo); + setFileInfo(info); + }, [props.file]); + + const handleCopyFileLink = () => { + try { + const url = `${window.location.origin}/${filePath}`; + navigator.clipboard.writeText(url); + toast.success("Download URL copied to clipboard."); + } catch (error: any) { + toast.error("Failed to copy Download URL to clipboard."); + console.error(error.message); + } finally { + setMenuOpen(false); + } + }; + const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => { + try { + const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined); + navigator.clipboard.writeText(url); + toast.success("Download URL copied to clipboard."); + } catch (error: any) { + toast.error("Failed to copy Download URL to clipboard."); + console.error(error.message); + } finally { + setMenuOpen(false); + } + }; + const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => { + window.open( + generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined), + "_blank", + ); + setMenuOpen(false); + }; + + return ( + <> + +
+ {props.file.thumbnailLink && + (props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? ( + {props.file.name} + ) : ( + + )} +
+ + {props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name} + {" "} +
+ {extensionInfo.join(" ・ ")} +
+
+ {fileInfo.join(" ・ ")} +
+
+
+
+ + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(!menuOpen); + }} + /> + +
+ + + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(false); + }} + className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${ + menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" + }`} + > +
+
+ {props.file.mimeType.includes("folder") ? ( + <> +
+ + Copy folder link +
+ + ) : ( + <> +
+ + Copy file link +
+
handleCopyDownloadLink(props.file, props.isProtected)} + > + + Copy download link +
+
+
handleDownload(props.file, props.isProtected)} + > + + Download +
+ + )} +
+
+ + + ); +} diff --git a/src/components/File/List.tsx b/src/components/File/List.tsx deleted file mode 100644 index bf2100d..0000000 --- a/src/components/File/List.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { drive_v3 } from "googleapis"; -import Link from "next/link"; -import { formatBytes, formatDate } from "utils/formatHelper"; -import { MdContentCopy, MdDownload } from "react-icons/md"; -import { getFileIcon } from "utils/mimeTypesHelper"; -import { BsFolderFill } from "react-icons/bs"; -import siteConfig from "config/site.config"; -import { createFileId } from "utils/driveHelper"; -import useCopyText from "hooks/useCopyText"; - -type Props = { - data: drive_v3.Schema$File; -}; - -export default function ListFile({ data }: Props) { - const isFolder = data.mimeType === "application/vnd.google-apps.folder"; - const Icon = isFolder - ? BsFolderFill - : getFileIcon(data.fileExtension as string, data.mimeType as string); - const copyText = useCopyText(); - - if (!data) return null; - - return ( - -
- {siteConfig.files.listUseFileIcon ? ( - {"icon"} - ) : ( - - )} -
- - {data.name} - -
- - {formatDate(new Date(data.modifiedTime as string))} - - {siteConfig.files.listMobileShowFileSize && ( - - {isFolder ? "" : ` ・ ${formatBytes(data.size as string)}`} - - )} -
-
-
-
- {formatDate(new Date(data.modifiedTime as string))} -
-
- {isFolder ? "" : formatBytes(data.size as string)} -
-
- - {!isFolder && ( - { - e.stopPropagation(); - }} - > - - - )} -
- - ); -} diff --git a/src/components/File/ListItem/index.tsx b/src/components/File/ListItem/index.tsx new file mode 100644 index 0000000..9c1e2f0 --- /dev/null +++ b/src/components/File/ListItem/index.tsx @@ -0,0 +1,232 @@ +import { Icon } from "@iconify/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; + +import ButtonIcon from "components/ButtonIcon"; +import ClickAway from "components/ClickAway"; +import Tooltip from "components/Tooltip"; + +import bytesToReadable from "utils/bytesFormat"; +import { stripExtension } from "utils/filesHelper"; +import { generatedDownloadLink } from "utils/generateDownloadLink"; +import { getPreviewIcon } from "utils/previewHelper"; +import { createDownloadToken } from "utils/tokenHelper"; + +import { IGDriveFiles } from "types/api/files"; + +interface ListItemProps { + file: IGDriveFiles; + isProtected: boolean; +} +export default function ListItem(props: ListItemProps) { + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(); + + const [fileInfo, setFileInfo] = useState([]); + const [extensionInfo, setExtensionInfo] = useState([]); + const [filePath, setFilePath] = useState(""); + + useEffect(() => { + const currentPath = router.asPath.slice(1); + const filePath = props.file.name; + const path = [currentPath, encodeURIComponent(filePath)].join("/"); + + // If start with /, remove it + if (path.startsWith("/")) { + setFilePath(path.slice(1)); + } else { + setFilePath(path); + } + }, [router.asPath, props.file.name]); + + useEffect(() => { + const info = []; + const extInfo = []; + props.file.modifiedTime && + info.push( + new Date(props.file.modifiedTime).toLocaleDateString("default", { + year: "numeric", + month: "short", + day: "numeric", + }), + ); + props.file.size && info.push(bytesToReadable(props.file.size)); + props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder"); + props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase()); + setExtensionInfo(extInfo); + setFileInfo(info); + }, [props.file]); + + const handleCopyFileLink = () => { + try { + const url = `${window.location.origin}/${filePath}`; + navigator.clipboard.writeText(url); + toast.success("Download URL copied to clipboard."); + } catch (error: any) { + toast.error("Failed to copy Download URL to clipboard."); + console.error(error.message); + } finally { + setMenuOpen(false); + } + }; + const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => { + try { + const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined); + navigator.clipboard.writeText(url); + toast.success("Download URL copied to clipboard."); + } catch (error: any) { + toast.error("Failed to copy Download URL to clipboard."); + console.error(error.message); + } finally { + setMenuOpen(false); + } + }; + const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => { + window.open( + generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined), + "_blank", + ); + setMenuOpen(false); + }; + + return ( + <> + +
+ {props.file.thumbnailLink && + (props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? ( + {props.file.name} + ) : ( + + )} +
+ + {props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name} + {" "} +
+ {extensionInfo.join(" ・ ")} +
+
+ {fileInfo.join(" ・ ")} +
+
+
+
+ + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(!menuOpen); + }} + /> + +
+ + + { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(false); + }} + className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${ + menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" + }`} + > +
+
+ {props.file.mimeType.includes("folder") ? ( + <> +
+ + Copy folder link +
+ + ) : ( + <> +
+ + Copy file link +
+
handleCopyDownloadLink(props.file, props.isProtected)} + > + + Copy download link +
+
+
handleDownload(props.file, props.isProtected)} + > + + Download +
+ + )} +
+
+ + + ); +} diff --git a/src/components/FilePreview/AudioPreview/index.tsx b/src/components/FilePreview/AudioPreview/index.tsx deleted file mode 100644 index c8210bc..0000000 --- a/src/components/FilePreview/AudioPreview/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { TFile } from "types/googleapis"; -import { drive_v3 } from "googleapis"; -import { useEffect, useState } from "react"; -import config from "config/site.config"; -import H5AudioPlayer from "react-h5-audio-player"; -import "react-h5-audio-player/lib/styles.css"; -import SWRLayout from "components/layout/SWRLayout"; - -type Props = { - data: TFile | drive_v3.Schema$File; -}; - -export default function AudioPreview({ data }: Props) { - const [isLoading, setIsLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(""); - const [audioSrc, setAudioSrc] = useState(""); - - useEffect(() => { - const timeout = setTimeout(() => { - setErrorMessage("Audio took too long to load"); - setIsLoading(false); - }, config.preview.timeout); - - const audio = new Audio(); - audio.src = `/media/${data.id}/${data.name}`; - audio.onloadeddata = () => { - setAudioSrc(audio.src); - setIsLoading(false); - clearTimeout(timeout); - }; - audio.onerror = (error: any) => { - setErrorMessage(error.message); - setIsLoading(false); - clearTimeout(timeout); - }; - - return () => { - clearTimeout(timeout); - }; - }, [data.id, data.name]); - - return ( -
- -
- -
-
-
- ); -} diff --git a/src/components/FilePreview/CodePreview/index.tsx b/src/components/FilePreview/CodePreview/index.tsx deleted file mode 100644 index b6bba13..0000000 --- a/src/components/FilePreview/CodePreview/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { drive_v3 } from "googleapis"; -import { useEffect, useState } from "react"; -import useSWR from "swr"; -import MarkdownRender from "components/utility/MarkdownRender"; -import { getCodeLanguage } from "utils/mimeTypesHelper"; -import { createFileId } from "utils/driveHelper"; -import SWRLayout from "components/layout/SWRLayout"; - -type Props = { - data: drive_v3.Schema$File; -}; - -export default function CodePreview({ data }: Props) { - const [codeContent, setCodeContent] = useState(""); - - const fileId = createFileId(data); - const { - data: swrData, - isLoading, - error, - } = useSWR(`/api/files/${fileId}?download=1`); - - useEffect(() => { - if (swrData) { - const isObject = typeof swrData === "object"; - setCodeContent( - `\`\`\`${getCodeLanguage(data.fileExtension as string)}\n${ - isObject ? JSON.stringify(swrData, null, 2) : swrData - }\`\`\``, - ); - } - }, [swrData, data.fileExtension]); - - return ( -
- -
- -
-
-
- ); -} diff --git a/src/components/FilePreview/DefaultPreview/index.tsx b/src/components/FilePreview/DefaultPreview/index.tsx deleted file mode 100644 index 952472f..0000000 --- a/src/components/FilePreview/DefaultPreview/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { TFile } from "types/googleapis"; -import { drive_v3 } from "googleapis"; -import ErrorFeedback from "components/APIFeedback/Error"; - -type Props = { - data: TFile | drive_v3.Schema$File; -}; - -export default function DefaultPreview({ data }: Props) { - return ( -
- -
- ); -} diff --git a/src/components/FilePreview/ImagePreview/index.tsx b/src/components/FilePreview/ImagePreview/index.tsx deleted file mode 100644 index 2dc3fd4..0000000 --- a/src/components/FilePreview/ImagePreview/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { drive_v3 } from "googleapis"; -import { useEffect, useState } from "react"; -import config from "config/site.config"; -import { reverseString } from "utils/hashHelper"; -import SWRLayout from "components/layout/SWRLayout"; -import { createFileId } from "utils/driveHelper"; - -type Props = { - data: drive_v3.Schema$File; - hash?: string; -}; - -export default function ImagePreview({ data, hash }: Props) { - const [isLoading, setIsLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(""); - const [imageSrc, setImageSrc] = useState(""); - - useEffect(() => { - const timeout = setTimeout(() => { - setErrorMessage("Image took too long to load"); - setIsLoading(false); - }, config.preview.timeout); - const image = new Image(); - image.src = `/api/files/${createFileId(data)}?download=1`; - image.onload = () => { - setImageSrc(image.src); - setIsLoading(false); - clearTimeout(timeout); - }; - image.onerror = (error: any) => { - setErrorMessage(error.message); - setIsLoading(false); - clearTimeout(timeout); - }; - - return () => { - clearTimeout(timeout); - }; - }, [data]); - - return ( -
- - {(data.name - -
- ); -} diff --git a/src/components/FilePreview/MarkdownPreview/index.tsx b/src/components/FilePreview/MarkdownPreview/index.tsx deleted file mode 100644 index b252b07..0000000 --- a/src/components/FilePreview/MarkdownPreview/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { drive_v3 } from "googleapis"; -import useSWR from "swr"; -import MarkdownRender from "components/utility/MarkdownRender"; -import { createFileId } from "utils/driveHelper"; -import SWRLayout from "components/layout/SWRLayout"; - -type Props = { - data: drive_v3.Schema$File; -}; - -export default function MarkdownPreview({ data }: Props) { - const fileId = createFileId(data); - const { - data: swrData, - error, - isLoading, - } = useSWR(`/api/files/${fileId}?download=1`); - - return ( -
- -
- -
-
-
- ); -} diff --git a/src/components/FilePreview/ModelPreview/index.tsx b/src/components/FilePreview/ModelPreview/index.tsx deleted file mode 100644 index 05739ed..0000000 --- a/src/components/FilePreview/ModelPreview/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { TFile } from "types/googleapis"; -import { drive_v3 } from "googleapis"; -import { Canvas, useFrame, useLoader } from "@react-three/fiber"; -import { get3DLoader } from "utils/mimeTypesHelper"; -import { ReactNode, Suspense, useRef } from "react"; -import { BufferGeometry } from "three"; -import ErrorFeedback from "components/APIFeedback/Error"; -import { OrbitControls, Stage } from "@react-three/drei"; - -type Props = { - data: TFile | drive_v3.Schema$File; -}; - -export default function ModelPreview({ data }: Props) { - const getLoader = get3DLoader(data.fileExtension as string); - - if (!getLoader) { - return ( -
- -
- ); - } - - // eslint-disable-next-line react-hooks/rules-of-hooks - const loader = useLoader( - getLoader, - `${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${data.id}/download`, - ); - - return ( -
-
- - - - - - - -
-
- ); -} - -type SceneProps = { - children: ReactNode | JSX.Element; -}; -function Scene({ children }: SceneProps): JSX.Element { - const controlsRef = useRef(); - - useFrame(() => { - if (controlsRef.current) { - controlsRef.current.update(); - } - }); - - return ( - <> - - {children} - - - - ); -} - -function Model({ model }: { model: BufferGeometry }): JSX.Element { - return ( - - - - ); -} diff --git a/src/components/FilePreview/OfficePreview/index.tsx b/src/components/FilePreview/OfficePreview/index.tsx deleted file mode 100644 index 3f8b9fb..0000000 --- a/src/components/FilePreview/OfficePreview/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { TFile } from "types/googleapis"; -import { drive_v3 } from "googleapis"; - -type Props = { - data: TFile | drive_v3.Schema$File; -}; - -export default function OfficePreview({ data }: Props) { - return ( -
- Office Preview -
- ); -} diff --git a/src/components/FilePreview/PDFPreview/index.tsx b/src/components/FilePreview/PDFPreview/index.tsx deleted file mode 100644 index f1acf31..0000000 --- a/src/components/FilePreview/PDFPreview/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { drive_v3 } from "googleapis"; -import { useState } from "react"; -import config from "config/site.config"; -import { createFileId } from "utils/driveHelper"; -import SWRLayout from "components/layout/SWRLayout"; - -type Props = { - data: drive_v3.Schema$File; -}; - -export default function PDFPreview({ data }: Props) { - const fileId = createFileId(data); - const [isLoading, setIsLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(""); - - const fileURL = `${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${fileId}?download=1`; - let providerURL; - switch (config.preview.pdfProvider) { - case "google": - providerURL = `https://drive.google.com/viewerng/viewer?embedded=true&url=${fileURL}`; - break; - case "mozilla": - providerURL = `https://mozilla.github.io/pdf.js/web/viewer.html?file=${fileURL}`; - break; - default: - providerURL = `https://drive.google.com/viewerng/viewer?embedded=true&url=${fileURL}`; - break; - } - - return ( -
- - <> - -
-