diff --git a/.env.example b/.env.example index d5646e0..fd4515f 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ -GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file" - +# Base64 Encoded Service Account JSON +GD_SERVICE_B64= +# Secret Key for Encryption ENCRYPTION_KEY= +# Index password, used when private mode is enabled SITE_PASSWORD= -# Only fill with the domain name, without the protocol and trailing slash -# This will be used, and will fallback to VERCEL_URL if it's not available -# Example: https://drive-demo.mbaharip.com -> drive-demo.mbaharip.com +# [Optional] Only domain, without protocol (ex: mbaharip.com) +# Needed if you're not using Vercel NEXT_PUBLIC_DOMAIN= diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..d81a4d4 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,43 @@ +/** @type {import("eslint").Linter.Config} */ +const config = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, + plugins: ["@typescript-eslint"], + extends: [ + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + ], + rules: { + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-misused-promises": [ + "error", + { + checksVoidReturn: { + attributes: false, + }, + }, + ], + "@next/next/no-img-element": "off", + }, + ignorePatterns: [], +}; + +module.exports = config; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index e844b5b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "@next/next/no-img-element": "off" - }, - "ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"] -} diff --git a/.gitignore b/.gitignore index e30d6e4..2c7c1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ next-env.d.ts /docs /src/pages/api/legacy/ /**/*/_legacy/ +# /**/*/_*/ /data /.*/ /src/_app diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 90% rename from .prettierrc.js rename to .prettierrc.cjs index 3c2169e..9f9a594 100644 --- a/.prettierrc.js +++ b/.prettierrc.cjs @@ -25,9 +25,9 @@ module.exports = { importOrder: [ "", "^~/(app|components)/(.*)$", - "^~/(utils|hooks|context)/(.*)$", + "^~/(utils|hooks|context|lib)/(.*)$", "^~/types/(.*)$", - "^~/(config|styles)/(.*)$", + "^~/(config|styles|actions|constant)/(.*)$", "^(config|actions)$", "^[./]", ], diff --git a/README.md b/README.md index 6639251..0a9c51e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![banner](/public/og.png) +![banner](/public/og.webp) > [!IMPORTANT] > It is **RECOMMENDED** to generate configuration from your deployment instead of demo site @@ -7,17 +7,15 @@

Demo Site · - Deploy / Version Upgrade Guide + Deploy / Version Upgrade Guide · - FAQ + How to Deploy (Youtube)

- Demo version - Dev + Demo version + Dev

---- - ## Table of Contents @@ -28,6 +26,8 @@ - [Known Issues](#known-issues) - [File size limit](#file-size-limit) - [No support for Google Docs, Sheets, and Slides](#no-support-for-google-docs-sheets-and-slides) + - [No support for Shortcut](#no-support-for-shortcut) + - [Encryption cause error](#encryption-cause-error) - [Can't seek on audio and video preview](#cant-seek-on-audio-and-video-preview) - [Shared Drive is not supported](#shared-drive-is-not-supported) - [Might be Implemented](#might-be-implemented) @@ -44,11 +44,11 @@ `next-gdrive-index` is a Google Drive directory index. The aim of this project is to simplify the process of sharing files using Google Drive, and also add some features that _I think_ is useful when sharing a files. -> This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by [SpencerWooo](https://github.com/spencerwooo). +This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by [SpencerWooo](https://github.com/spencerwooo). ## Why I made this? -> **TLDR;** +> **TLDR;** \ > It is cheaper to use Google Drive than other similar service There are a lot cloud storage service like Onedrive, Dropbox, Mega, etc. @@ -64,8 +64,8 @@ _Price are converted to IDR, since it's easier for me to compare this using my o | **Dropbox**\* | 2GB | 2TB | ~191k IDR
~159k IDR | 10.5GB / 1k / mo
12.5GB / 1k / yr | O | | **MEGA** | **20GB** | 2TB | ~174k IDR
~145k IDR | 11.5GB / 1k / mo
13.8GB / 1k / yr | O | -\* Price are in USD, and there are no regional price for IDR -\*\* There are no 2TB plan +> \* Price are in USD, and there are no regional price for IDR \ +> \*\* There are no 2TB plan By using this data, I picked Google Drive instead other service. I know there are a lot of people selling cheap education account especially for Google Drive and Onedrive, but most of the time those account doesn't last long. @@ -82,11 +82,12 @@ I know there are a lot of people selling cheap education account especially for - Document preview - Code / Text / Markdown preview - Manga preview (cbz) +- **Embed media**, embed media like video or audio to your site - **File search**, search by the file or folder name - **Direct download**, download directly via API route instead of google drive link ( Size limit can be adjusted ) - **Raw file link**, embed your media files - **Light/Dark mode**, choose your side! -- **Customizable Theme**, we are using `shadcn/ui` now! you can customize your site [https;//drive-demo.mbaharip.com/deploy#theme] +- **Customizable Theme**, we are using `shadcn/ui` now! you can customize your site with the theme you like - **Links**, add social, or information link on the navbar - **Sponsors**, using this for thing for community? add a sponsor / donate button on your navbar! @@ -94,21 +95,32 @@ I know there are a lot of people selling cheap education account especially for ### File size limit +> [!IMPORTANT] > This only apply if `maxFileSize` is enabled / more than 0 You need to set the file sharing permission to `Anyone with the link can view` on the root folder. **Why?** -The download link will be redirected if the file you're trying to download is bigger than the `maxFileSize`, and most of platform are limiting the response body size (ex: Vercel limit is 4MB). +The download link will be redirected if the file you're trying to download is bigger than the `maxFileSize`, and most of platform are limiting the response body size (ex: Vercel limit is 4MB). If you don't set the permission, people can't access or download the file. -This will expose the file ID, and people can access the file directly from Google Drive. -But it only apply to the file, and they can't see or browse the folder directly from Google Drive. +> [!WARNING] +> This will **expose the file ID** +> While they can view the file directly from Google Drive, they can't see or browse the folder that contains the file from Google Drive. ### 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. +### No support for Shortcut + +While I think it's important, I don't have any plan to implement this for now. + +### ~Encryption cause error~ + +~~It seems the configurator are generating wrong encrypted folder ID, so it will cause error when you try to access the folder.~~ +Should be fixed on v2.0.4, waiting for feedback / confirmation + ### ~~Can't seek on audio and video preview~~ ~~It looks like you can't seek the audio and video preview, so you need to listen / watch from the beginning.~~ @@ -133,7 +145,7 @@ It's either from multiple Google Drive account with multiple Service Account, or ### Authentication -Probably a good feature if you are a content creator that only want the one who subscribed to you get the files. +Probably a good feature if you are a content creator that only want the one who subscribed to you get the files. It might need a database, but idk if I can implement it without the need of database ## Running on Local @@ -163,7 +175,7 @@ It might need a database, but idk if I can implement it without the need of data Want to add new feature or improve the existing one? or you find a bug and fixed it yourself? - Please check the issue tab first to see if someone already reporting a bug, or if you want to check any new feature / enhancement that not yet implemented -- Use code base from `v2` branch instead of the `main`, since it was made for development on current version +- Use code base from the latest version branch instead of the `main`, since it was made for development on current version - Create a Pull Request, and wait for me checking your code ## Support and Donations diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..d1a8d9d Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json index 848837b..33949c7 100644 --- a/components.json +++ b/components.json @@ -1,17 +1,21 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", - "css": "src/app/globals.css", + "css": "src/styles/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "~/components", - "utils": "~/utils/cn" - } + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "iconLibrary": "lucide" } diff --git a/next.config.js b/next.config.js index f0754a2..8ca9368 100644 --- a/next.config.js +++ b/next.config.js @@ -1,23 +1,17 @@ +/* eslint-disable */ +const path = require("path"); + +/** @type {import("next").NextConfig} */ const nextConfig = { reactStrictMode: true, + pageExtensions: ["tsx", "ts"], webpack: (config) => { - config.resolve.alias.canvas = false; - + config.resolve.alias = { + ...config.resolve.alias, + "~": path.resolve(__dirname, "./"), + }; return config; }, - // async headers() { - // return [ - // { - // source: "/:path*", - // headers: [ - // { - // key: "Cache-Control", - // value: "max-age=300, s-maxage=300, stale-while-revalidate, public", - // }, - // ], - // }, - // ]; - // }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index 0fe4e26..a32e083 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,63 @@ { "name": "next-gdrive-index", - "version": "2.0.3", + "version": "2.0.4", "private": true, "scripts": { "dev": "next dev -p 3000", + "dev:turbo": "next dev -p 3000 --turbopack", "build": "next build", "start": "next start", "lint": "next lint", - "cypress": "cypress open", - "pretty": "prettier --write ." + "lint:fix": "next lint --fix", + "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\" --cache", + "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json}\" --cache" }, "dependencies": { - "@cyntler/react-doc-viewer": "^1.13.0", - "@hookform/resolvers": "^3.3.4", + "@hookform/resolvers": "^3.10.0", + "@oslojs/encoding": "^1.1.0", "@radix-ui/react-accordion": "^1.1.2", - "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-collapsible": "^1.0.3", - "@radix-ui/react-context-menu": "^2.1.5", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-context-menu": "^2.2.4", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-hover-card": "^1.0.7", - "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-navigation-menu": "^1.1.4", - "@radix-ui/react-popover": "^1.0.7", - "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.1.3", - "@radix-ui/react-scroll-area": "^1.0.5", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slider": "^1.1.2", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", - "@radix-ui/react-tooltip": "^1.0.7", - "axios": "^1.3.5", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "cmdk": "^1.0.0", - "date-fns": "^3.6.0", - "embla-carousel-react": "^8.0.2", + "@radix-ui/react-tooltip": "^1.1.4", + "@tanstack/react-virtual": "^3.11.2", + "@vidstack/react": "^1.12.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.0", + "embla-carousel-react": "^8.5.1", "fflate": "^0.8.2", - "googleapis": "^118.0.0", - "input-otp": "^1.2.3", - "jsonwebtoken": "^9.0.0", - "lucide-react": "^0.363.0", - "next": "^14.1.4", - "next-themes": "^0.3.0", + "framer-motion": "^11.13.3", + "googleapis": "^144.0.0", + "lucide-react": "^0.468.0", + "next": "^15.1.6", + "next-themes": "^0.4.4", "nextjs-toploader": "^1.6.11", - "plyr-react": "^5.3.0", - "react": "^18", - "react-colorful": "^5.6.1", - "react-day-picker": "^8.10.0", - "react-dom": "^18", - "react-h5-audio-player": "^3.9.1", - "react-hook-form": "^7.51.2", - "react-hot-toast": "^2.4.1", - "react-is": "^18.2.0", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.2", "react-markdown": "^8.0.7", - "react-player": "^2.13.0", - "react-popper": "^2.3.0", - "react-resizable-panels": "^2.0.16", - "react-toastify": "^9.1.3", "rehype-katex": "^6.0.3", "rehype-prism-plus": "^1.6.3", "rehype-raw": "6.1.1", @@ -76,33 +66,34 @@ "remark-math": "^5.1.1", "remark-slug": "^7.0.1", "remark-toc": "^8.0.1", - "sonner": "^1.4.41", - "tailwind-merge": "^2.2.2", + "server-only": "^0.0.1", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", - "use-debouncy": "^5.0.1", - "vaul": "^0.9.0", - "zod": "^3.22.4" + "vaul": "^1.1.1", + "zod": "^3.24.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/eslint": "^8.56.10", "@types/mime-types": "^2.1.1", "@types/node": "^20", "@types/nprogress": "^0.2.3", - "@types/react": "^18", - "@types/react-dom": "^18", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@types/three": "^0.150.2", + "@typescript-eslint/eslint-plugin": "^8.1.0", + "@typescript-eslint/parser": "^8.1.0", "autoprefixer": "^10.4.19", "encoding": "^0.1.13", - "eslint": "8.38.0", - "eslint-config-next": "^14.1.4", + "eslint": "^8.57.0", + "eslint-config-next": "^15.0.1", "postcss": "^8.4.38", "prettier": "3.0.0", "prettier-plugin-tailwindcss": "0.5.12", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5.5.3" } } diff --git a/public/og.png b/public/og.png deleted file mode 100644 index ad92661..0000000 Binary files a/public/og.png and /dev/null differ diff --git a/public/og.webp b/public/og.webp new file mode 100644 index 0000000..f37e8a4 Binary files /dev/null and b/public/og.webp differ diff --git a/src/actions/configuration.ts b/src/actions/configuration.ts new file mode 100644 index 0000000..7dd7b97 --- /dev/null +++ b/src/actions/configuration.ts @@ -0,0 +1,281 @@ +"use server"; + +import { type AsyncZippable, strToU8, zipSync } from "fflate"; +import { type z } from "zod"; +import { type ActionResponseSchema } from "~/types"; + +import { + configurationTemplate, + environmentTemplate, + parseEnvironment, + parseVersion1Config, + parseVersion2Config, +} from "~/lib/configurationHelper"; +import { base64Decode, base64Encode, encryptionService } from "~/lib/utils.server"; + +import { type Schema_App_Configuration, type Schema_App_Configuration_Env } from "~/types/schema"; + +export async function GenerateServiceAccountB64(serviceAccount: string): Promise> { + const b64 = base64Encode(serviceAccount, "standard"); + + // Test + const decoded = base64Decode(b64, "standard"); + if (decoded === null) { + return { + success: false, + message: "Failed to decode the service account", + error: "Something went wrong while encoding the service account, please try again", + }; + } + if (decoded !== serviceAccount) { + return { + success: false, + message: "Encode failed to match the original string", + error: "Something went wrong while testing the encoding, please try again", + }; + } + + return { + success: true, + message: "Service account generated", + data: b64, + }; +} + +export async function ProcessEnvironmentConfig( + configuration: string, +): Promise>> { + const data = parseEnvironment(configuration); + if ("message" in data && "details" in data) { + return { + success: false, + message: data.message, + error: data.details.join("\n"), + }; + } + // Verify service account + const serviceAccount = data.GD_SERVICE_B64; + const decoded = base64Decode(serviceAccount, "standard"); + if (decoded === null) { + data.GD_SERVICE_B64 = ""; + return { + success: true, + data, + message: "Environment loaded, but service account is invalid", + }; + } + + return { + success: true, + message: "Environment loaded", + data, + }; +} + +export async function ProcessConfiguration( + configuration: string, + version: "v1" | "v2" | "latest", +): Promise, "environment">>> { + const data = version === "v1" ? parseVersion1Config(configuration) : parseVersion2Config(configuration); + if ("message" in data && "details" in data) { + return { + success: false, + message: data.message, + error: data.details.join("\n"), + }; + } + + return { + success: true, + message: "Configuration loaded, but folder ID are not processed", + data, + }; +} + +export async function GenerateConfiguration( + values: z.infer, +): Promise> { + const configurationMap: { key: string; value: string }[] = [ + { + key: "version", + value: values.version, + }, + { + key: "showGuideButton", + value: values.site.guideButton.toString(), + }, + { + key: "cacheControl", + value: `${values.api.cache.public ? "public, " : ""}max-age=${values.api.cache.maxAge}, s-maxage=${ + values.api.cache.sMaxAge + }${values.api.cache.staleWhileRevalidate ? ", stale-while-revalidate" : ""}`, + }, + { + key: "api.rootFolder", + value: await encryptionService.encrypt(values.api.rootFolder, values.environment.ENCRYPTION_KEY), + }, + { + key: "api.isTeamDrive", + value: values.api.isTeamDrive.toString(), + }, + { + key: "api.sharedDrive", + value: values.api.sharedDrive + ? await encryptionService.encrypt(values.api.sharedDrive, values.environment.ENCRYPTION_KEY) + : "", + }, + { + key: "api.itemsPerPage", + value: values.api.itemsPerPage.toString(), + }, + { + key: "api.searchResult", + value: values.api.searchResult.toString(), + }, + { + key: "api.specialFile.password", + value: values.api.specialFile.password, + }, + { + key: "api.specialFile.readme", + value: values.api.specialFile.readme, + }, + { + key: "api.specialFile.banner", + value: values.api.specialFile.banner, + }, + { + key: "api.hiddenFiles", + value: `[${values.api.hiddenFiles.map((file) => `"${file}"`).join(", ")}]`, + }, + { + key: "api.proxyThumbnail", + value: values.api.proxyThumbnail.toString(), + }, + { + key: "api.streamMaxSize", + value: values.api.streamMaxSize.toString(), + }, + { + key: "api.maxFileSize", + value: values.api.maxFileSize.toString(), + }, + { + key: "api.allowDownloadProtectedFile", + value: values.api.allowDownloadProtectedFile.toString(), + }, + { + key: "api.temporaryTokenDuration", + value: values.api.temporaryTokenDuration.toString(), + }, + { + key: "site.siteName", + value: values.site.siteName, + }, + { + key: "site.siteNameTemplate", + value: values.site.siteNameTemplate, + }, + { + key: "site.siteDescription", + value: values.site.siteDescription, + }, + { + key: "site.siteAuthor", + value: values.site.siteAuthor, + }, + { + key: "site.robots", + value: values.site.robots, + }, + { + key: "site.twitterHandle", + value: values.site.twitterHandle, + }, + { + key: "site.showFileExtension", + value: values.site.showFileExtension.toString(), + }, + { + key: "site.privateIndex", + value: values.site.privateIndex.toString(), + }, + { + key: "site.breadcrumbMax", + value: values.site.breadcrumbMax.toString(), + }, + { + key: "site.toaster.position", + value: values.site.toaster.position, + }, + { + key: "site.toaster.duration", + value: values.site.toaster.duration.toString(), + }, + { + key: "site.navbarItems", + value: JSON.stringify(values.site.navbarItems, null, 2), + }, + { + key: "site.supports", + value: JSON.stringify(values.site.supports, null, 2), + }, + { + key: "site.footer", + value: JSON.stringify(values.site.footer, null, 2), + }, + ]; + let configuration = configurationTemplate; + for (const { key, value } of configurationMap) { + configuration = configuration.replace(`{{ ${key} }}`, value); + } + + const envMap: { key: string; value: string }[] = [ + { + key: "serviceAccount", + value: values.environment.GD_SERVICE_B64, + }, + { + key: "key", + value: values.environment.ENCRYPTION_KEY, + }, + { + key: "password", + value: values.environment.SITE_PASSWORD ?? "", + }, + { + key: "domain", + value: values.environment.NEXT_PUBLIC_DOMAIN ?? "", + }, + ]; + let env = environmentTemplate; + for (const { key, value } of envMap) { + env = env.replace(`{{ ${key} }}`, value); + } + + const struct: AsyncZippable = { + ".env": [ + strToU8(env), + { + level: 9, + }, + ], + "gIndex.config.ts": [ + strToU8(configuration), + { + level: 9, + }, + ], + }; + const zip = zipSync(struct); + + return { + success: true, + message: "Configuration generated", + data: { + configuration, + env, + zip: new Blob([zip], { type: "application/zip" }), + }, + }; +} diff --git a/src/actions/files.ts b/src/actions/files.ts new file mode 100644 index 0000000..d070e7c --- /dev/null +++ b/src/actions/files.ts @@ -0,0 +1,374 @@ +"use server"; + +import { type z } from "zod"; +import { type ActionResponseSchema } from "~/types"; + +import { encryptionService, gdrive } from "~/lib/utils.server"; + +import { Schema_File, Schema_File_Shortcut } from "~/types/schema"; + +import config from "config"; + +/** + * List files in a folder + * @param {object} options + * @param {string} options.id (optional) - Folder ID to fetch, default is root folder + * @param {string} options.pageToken (optional) - Page token to fetch next page + */ +export async function ListFiles({ id, pageToken }: { id?: string; pageToken?: string } = {}): Promise< + ActionResponseSchema<{ + files: z.infer[]; + nextPageToken?: string | null; + }> +> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and "); + const filterQuery: string = [...config.apiConfig.defaultQuery, `'${decryptedId}' in parents`, filterName].join( + " and ", + ); + + const { data } = await gdrive.files.list({ + q: filterQuery, + fields: `files(${config.apiConfig.defaultField}), nextPageToken`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: pageToken, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + if (!data.files?.length) + return { + success: true, + message: "No files found", + data: { + files: [], + nextPageToken: data.nextPageToken, + }, + }; + + const files: z.infer[] = []; + for (const file of data.files) { + files.push({ + encryptedId: await encryptionService.encrypt(file.id!), + encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined, + name: file.name!, + mimeType: file.mimeType!, + trashed: file.trashed ?? false, + modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(), + fileExtension: file.fileExtension ?? 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 + ? { + durationMillis: Number(file.videoMediaMetadata.durationMillis), + height: Number(file.videoMediaMetadata.height), + width: Number(file.videoMediaMetadata.width), + } + : undefined, + }); + } + + const parsed = Schema_File.array().safeParse(files); + if (!parsed.success) + return { + success: false, + message: "Failed to parse files", + error: parsed.error.message, + }; + + return { + success: true, + message: "Files found", + data: { + files: parsed.data, + nextPageToken: data.nextPageToken, + }, + }; +} + +/** + * Get file details + * @param id - File ID to fetch + */ +export async function GetFile(id: string): Promise | null>> { + const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder); + + const { data } = await gdrive.files.get({ + fileId: decryptedId, + fields: config.apiConfig.defaultField, + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + if (!data.id) + return { + success: false, + message: "File not found", + error: "NotFound", + }; + + const file: z.infer = { + encryptedId: await encryptionService.encrypt(data.id), + encryptedWebContentLink: data.webContentLink ? await encryptionService.encrypt(data.webContentLink) : undefined, + name: data.name!, + mimeType: data.mimeType!, + trashed: data.trashed ?? false, + modifiedTime: new Date(data.modifiedTime!).toLocaleDateString(), + fileExtension: data.fileExtension ?? undefined, + size: data.size ? Number(data.size) : undefined, + thumbnailLink: data.thumbnailLink ?? undefined, + imageMediaMetadata: data.imageMediaMetadata + ? { + width: Number(data.imageMediaMetadata.width), + height: Number(data.imageMediaMetadata.height), + rotation: Number(data.imageMediaMetadata.rotation ?? 0), + } + : undefined, + videoMediaMetadata: data.videoMediaMetadata + ? { + durationMillis: Number(data.videoMediaMetadata.durationMillis), + height: Number(data.videoMediaMetadata.height), + width: Number(data.videoMediaMetadata.width), + } + : undefined, + }; + + const parsed = Schema_File.safeParse(file); + if (!parsed.success) + return { + success: false, + message: "Failed to parse file", + error: parsed.error.message, + }; + + return { + success: true, + message: "File found", + data: parsed.data, + }; +} + +/** + * Get readme file inside a folder + * @param id - Folder ID to fetch, default is root folder + */ +export async function GetReadme(id: string | null = null): Promise< + ActionResponseSchema<{ + type: "markdown" | "txt"; + content: string; + } | null> +> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const filterQuery: string = [ + "trashed = false", + "(not mimeType contains 'folder')", + `name = '${config.apiConfig.specialFile.readme}'`, + `'${decryptedId}' in parents`, + ].join(" and "); + + const { data } = await gdrive.files.list({ + q: filterQuery, + fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: undefined, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + + if (!data.files?.length) + return { + success: true, + message: "No README found", + data: null, + }; + + let file; + if (data.files.length === 1) { + file = data.files[0]; + } else { + file = data.files.find((file) => file.mimeType === "text/markdown"); + if (!file) { + file = data.files.find((file) => file.mimeType === "application/vnd.google-apps.shortcut"); + } + } + + if (!file) + return { + success: true, + message: "No README found", + data: null, + }; + + switch (file.mimeType) { + case "application/vnd.google-apps.shortcut": + const { data: shortcutData } = await gdrive.files.get({ + fileId: file.id!, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + const parsedData = Schema_File_Shortcut.safeParse(shortcutData); + if (!parsedData.success) + return { + success: false, + message: "Failed to parse shortcut data", + error: parsedData.error.message, + }; + + if ( + !parsedData.data.shortcutDetails.targetId || + (parsedData.data.shortcutDetails.targetMimeType !== "text/markdown" && + parsedData.data.shortcutDetails.targetMimeType !== "text/plain") + ) + return { + success: true, + message: "Shortcut target is not a markdown file or plain text", + data: null, + }; + + const { data: shortcutContent } = await gdrive.files.get( + { + fileId: parsedData.data.shortcutDetails.targetId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "text", + }, + ); + + return { + success: true, + message: "README found", + data: { + type: parsedData.data.shortcutDetails.targetMimeType === "text/markdown" ? "markdown" : "txt", + content: shortcutContent as string, + }, + }; + case "text/markdown": + case "text/plain": + const { data: content } = await gdrive.files.get( + { + fileId: file.id!, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "text", + }, + ); + return { + success: true, + message: "README found", + data: { + type: file.mimeType === "text/markdown" ? "markdown" : "txt", + content: content as string, + }, + }; + default: + return { + success: true, + message: "No README found", + data: null, + }; + } +} + +/** + * Get banner file inside a folder + * @param id - Folder ID to fetch, default is root folder + */ +export async function GetBanner(id: string | null = null): Promise> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const filterQuery: string = [ + ...config.apiConfig.defaultQuery, + `name contains '${config.apiConfig.specialFile.banner}'`, + `'${decryptedId}' in parents`, + ].join(" and "); + + const { data } = await gdrive.files.list({ + q: filterQuery, + fields: `files(${config.apiConfig.defaultField},parents)`, + orderBy: config.apiConfig.defaultOrder, + pageSize: config.apiConfig.itemsPerPage, + pageToken: undefined, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + if (!data.files?.length) + return { + success: true, + message: "No banner found", + data: null, + }; + + const encryptedId = await encryptionService.encrypt(data.files[0]?.id ?? ""); + return { + success: true, + message: "Banner found", + data: encryptedId, + }; +} + +/** + * Get content of text file + * @param id - File ID to fetch + */ +export async function GetContent(id: string): Promise> { + const decryptedId = await encryptionService.decrypt(id); + + const { data, status, statusText } = await gdrive.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "text", + }, + ); + if (status !== 200) + return { + success: false, + message: "Failed to fetch content", + error: statusText, + }; + + return { + success: true, + message: "Content found", + data: data as string, + }; +} diff --git a/src/actions/password.ts b/src/actions/password.ts new file mode 100644 index 0000000..b1d14ff --- /dev/null +++ b/src/actions/password.ts @@ -0,0 +1,249 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { COOKIES_NAME, COOKIES_OPTIONS } from "~/constant"; +import { type ActionResponseSchema } from "~/types"; + +import { toUrlPath } from "~/lib/utils"; +import { encryptionService, gdriveNoCache } from "~/lib/utils.server"; + +import config from "config"; + +/** + * Remove all saved passwords. + */ +export async function ClearSavedPasswords(): Promise { + const store = await cookies(); + if (store.has(COOKIES_NAME.indexPassword)) { + store.delete(COOKIES_NAME.indexPassword); + } + if (store.has(COOKIES_NAME.folderPassword)) { + store.delete(COOKIES_NAME.folderPassword); + } + revalidatePath("/", "layout"); + + return { + success: true, + message: "All saved passwords cleared", + data: undefined, + }; +} + +/** + * Check if the password of the index is correct. + * Only used if the index is password-protected. + */ +export async function CheckIndexPassword(): Promise { + let response: ActionResponseSchema = { + success: true, + message: "Index is public", + data: undefined, + }; + + // Skip if public + if (!config.siteConfig.privateIndex) return response; + + // Check if password is set + if (!process.env.SITE_PASSWORD) { + response = { + success: false, + message: "Password is not set", + error: + "Index password is not set, please set the `SITE_PASSWORD` environment variable, or disable `privateIndex` in the config file!", + }; + return response; + } + const store = await cookies(); + + const password = store.get(COOKIES_NAME.indexPassword)?.value; + if (!password) { + response = { + success: false, + message: "Index is locked", + error: "", + }; + return response; + } + const decryptedPassword = await encryptionService.decrypt(password); + + if (decryptedPassword !== process.env.SITE_PASSWORD) { + response = { + success: false, + message: "Incorrect password", + error: "Incorrect password, please try again!", + }; + return response; + } + + response = { + success: true, + message: "Index is unlocked", + data: undefined, + }; + return response; +} + +/** + * Set the index / full site password. + * @param {string} password - The password to set. + * @returns {ActionResponseSchema} + */ +export async function SetIndexPassword(password: string): Promise { + try { + const store = await cookies(); + const encryptedPassword = await encryptionService.encrypt(password); + store.set(COOKIES_NAME.indexPassword, encryptedPassword, COOKIES_OPTIONS); + revalidatePath("/", "layout"); + + return { + success: true, + message: "Password set", + data: undefined, + }; + } catch (error) { + const e = error as Error; + console.error(`[Actions/Password/SetIndexPassword] ${e.message}`); + return { + success: false, + message: "Failed to set password", + error: e.message, + }; + } +} + +/** + * Check if the password of the current folder is correct. + */ +export async function CheckPagePassword( + paths: { + path: string; + id: string; + }[], +): Promise { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const pathsArray = paths; + + const folderIds: string[] = []; + for (const path of pathsArray) { + const decryptedId = await encryptionService.decrypt(path.id); + if (decryptedId === decryptedRootId) continue; + folderIds.push(decryptedId); + } + + const filterQuery: string = [ + `name = '${config.apiConfig.specialFile.password}'`, + `trashed = false`, + `(${folderIds.map((id) => `'${id}' in parents`).join(" or ")})`, + ].join(" and "); + + const findPasswordResponse = await gdriveNoCache.files.list({ + q: filterQuery, + fields: "files(id,name,mimeType,parents)", + pageSize: 1000, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + if (!findPasswordResponse.data.files?.length) + return { + success: true, + message: `No password file found on current path (${toUrlPath(paths)})`, + data: undefined, + }; + + const protectedFolderPassword = findPasswordResponse.data.files + .filter((item) => folderIds.includes(item.parents?.[0] ?? "")) + ?.shift(); + if (!protectedFolderPassword) + return { + success: true, + message: `All folders on current path are public (${toUrlPath(paths)})`, + data: undefined, + }; + + const store = await cookies(); + const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record; + + const currentFolderIndex = folderIds.findIndex((item) => item === protectedFolderPassword.parents?.[0]); + const currentFolder = paths[currentFolderIndex]!; + const currentFolderPath = toUrlPath(paths.slice(0, currentFolderIndex + 1)); + const currentFolderPassword = storedPassword[currentFolderPath]; + + if (!currentFolderPassword) + return { + success: false, + message: "Folder is locked", + error: `Please enter password for '${currentFolderPath}'`, + }; + + const savedPasswordValue = await encryptionService.decrypt(currentFolderPassword); + const { data: passwordFile } = await gdriveNoCache.files.get( + { + fileId: protectedFolderPassword.id!, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + }, + { + responseType: "text", + }, + ); + if (!passwordFile) + return { + success: false, + message: "Password file not found", + error: `Failed to get password file for '${currentFolder.path}', please try again!`, + }; + + if (savedPasswordValue !== passwordFile) + return { + success: false, + message: "Incorrect password", + error: `The password for '${currentFolder.path}' is incorrect`, + }; + + return { + success: true, + message: "Folder is unlocked", + data: undefined, + }; +} + +/** + * Set the password for the current folder. + * @param path - The path of the folder. + * @param password - The password to set. + */ +export async function SetPagePassword(path: string, password: string): Promise { + try { + const store = await cookies(); + const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record; + const encryptedPassword = await encryptionService.encrypt(password); + + storedPassword[path] = encryptedPassword; + store.set(COOKIES_NAME.folderPassword, JSON.stringify(storedPassword), COOKIES_OPTIONS); + revalidatePath(path, "page"); + + return { + success: true, + message: "Password set", + data: undefined, + }; + } catch (error) { + const e = error as Error; + console.error(`[Actions/Password/SetPagePassword] ${e.message}`); + return { + success: false, + message: "Failed to set password", + error: e.message, + }; + } +} diff --git a/src/actions/paths.ts b/src/actions/paths.ts new file mode 100644 index 0000000..a536e60 --- /dev/null +++ b/src/actions/paths.ts @@ -0,0 +1,160 @@ +"use server"; + +import { type ActionResponseSchema } from "~/types"; + +import { encryptionService, gdrive } from "~/lib/utils.server"; + +import config from "config"; + +/** + * Get file paths from the root folder to the file. + * @param {string} fileName - The file name. + * @param {string} parentId - The parent ID of the file. + * @returns {string} - The file path. + */ +export async function GetFilePaths(fileName: string, parentId?: string): Promise> { + const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder); + if (!decryptedRootId) + return { success: false, message: "Failed to decrypt root folder ID", error: "Failed to decrypt root folder ID" }; + + const paths: string[] = [fileName]; + let parentIdTemp = parentId; + while (parentIdTemp) { + if (parentIdTemp === decryptedRootId) break; + + const { data } = await gdrive.files.get({ + fileId: parentIdTemp, + fields: "id,name,parents", + supportsAllDrives: config.apiConfig.isTeamDrive, + }); + if (!data.name) break; + + paths.unshift(data.name); + parentIdTemp = data.parents?.[0]; + } + + return { success: true, message: "File paths retrieved", data: paths.join("/") }; +} + +type PathFetch = { + index: number; + path: string; + data: { + id: string; + parents?: string; + mimeType: string; + }[]; +}; +/** + * Validate paths and return the ID of each path. + * @param paths - The paths to validate. + * @returns {ActionResponseSchema<{ id: string; path: string; mimeType: string; }[]>} - The validated paths. + */ +export async function ValidatePaths( + paths: string[], +): Promise> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const promises: Promise[] = []; + for (const [index, path] of paths.entries()) { + const list = gdrive.files + .list({ + q: `name = '${decodeURIComponent(path)}' and trashed = false`, + fields: "files(id, name, mimeType, parents)", + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }) + .then(({ data }) => { + if (!data.files?.length) return null; + + const object: PathFetch = { + index, + path, + data: data.files.map((file) => ({ + id: file.id!, + parents: file.parents?.[0], + mimeType: file.mimeType!, + })), + }; + return object; + }); + promises.push(list); + } + + const pathData = await Promise.all(promises); + + // Try to find index of invalid path (null value from promise) + // If found, return error message + const invalidPathIndex = pathData.findIndex((path) => !path); + if (invalidPathIndex !== -1) + return { + success: false, + message: "Invalid path", + error: `Failed to find path: ${paths[invalidPathIndex]}`, + }; + const filteredPathData = pathData.filter((path) => path) as PathFetch[]; + + // Validate each path + let isValid = true; + let invalidPath: string | undefined; + const validatedPaths: PathFetch[] = []; + + for (const path of filteredPathData) { + if (!isValid) break; + if (!path.data.length) { + isValid = false; + invalidPath = path.path; + break; + } + + /** + * Check for current path index + * If it's 0 / the first path, make sure the parent is the root folder + * If it's not, make sure the parent is the previous path's ID + */ + for (const item of path.data) { + if (path.index === 0) { + if (item.parents !== decryptedRootId && item.parents !== decryptedSharedDrive) break; + validatedPaths.push(path); + } else { + const previousPath = validatedPaths[path.index - 1]; + if (!previousPath) break; + if (item.parents !== previousPath.data?.[0]?.id) break; + validatedPaths.push(path); + } + } + } + if (validatedPaths.length !== filteredPathData.length) { + isValid = false; + invalidPath = filteredPathData[validatedPaths.length]?.path; + } + if (!isValid) + return { + success: false, + message: "Invalid path", + error: invalidPath ? `Failed to validate path: ${invalidPath}` : "Failed when validating paths", + }; + + const response: { path: string; id: string; mimeType: string }[] = []; + for (const item of validatedPaths) { + response.push({ + id: await encryptionService.encrypt(item.data[0]?.id ?? ""), + path: decodeURIComponent(item.path), + mimeType: item.data[0]?.mimeType ?? "", + }); + } + + return { + success: true, + message: "Paths validated", + data: response, + }; +} diff --git a/src/actions/search.ts b/src/actions/search.ts new file mode 100644 index 0000000..47c9c11 --- /dev/null +++ b/src/actions/search.ts @@ -0,0 +1,116 @@ +"use server"; + +import { type z } from "zod"; +import { type ActionResponseSchema } from "~/types"; + +import { encryptionService, gdrive } from "~/lib/utils.server"; + +import { Schema_File } from "~/types/schema"; + +import config from "config"; + +import { GetFilePaths } from "./paths"; + +export async function SearchFiles(query: string): Promise[]>> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and "); + const filterQuery: string = [...config.apiConfig.defaultQuery, `name contains '${query}'`, filterName].join(" and "); + + const { data } = await gdrive.files.list({ + q: filterQuery, + fields: `files(${config.apiConfig.defaultField})`, + orderBy: "name_natural desc", + pageSize: config.apiConfig.searchResult, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + if (!data.files?.length) + return { + success: true, + message: "No files found", + data: [], + }; + + const files: z.infer[] = []; + for (const file of data.files) { + files.push({ + encryptedId: await encryptionService.encrypt(file.id!), + encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined, + name: file.name!, + mimeType: file.mimeType!, + trashed: file.trashed ?? false, + modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(), + fileExtension: file.fileExtension ?? 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 + ? { + durationMillis: Number(file.videoMediaMetadata.durationMillis), + height: Number(file.videoMediaMetadata.height), + width: Number(file.videoMediaMetadata.width), + } + : undefined, + }); + } + + const parsed = Schema_File.array().safeParse(files); + if (!parsed.success) + return { + success: false, + message: "Failed to parse search results", + error: parsed.error.message, + }; + + return { + success: true, + message: "Search completed", + data: parsed.data, + }; +} + +export async function GetSearchResultPath(id: string): Promise> { + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder); + + const { data } = await gdrive.files.get({ + fileId: decryptedId, + fields: "id,name,parents", + supportsAllDrives: isSharedDrive, + }); + if (!data) { + return { + success: false, + message: "Failed to get file information", + error: "Failed to get file information, might be due to invalid file ID!", + }; + } + + const filePath = await GetFilePaths(data.name!, data.parents?.[0]); + if (!filePath.success) + return { + success: false, + message: "Failed to get file path", + error: filePath.error, + }; + + return { + success: true, + message: "File path found", + data: filePath.data, + }; +} diff --git a/src/actions/token.ts b/src/actions/token.ts new file mode 100644 index 0000000..0af65d2 --- /dev/null +++ b/src/actions/token.ts @@ -0,0 +1,71 @@ +"use server"; + +import { type z } from "zod"; +import { type ActionResponseSchema } from "~/types"; + +import { encryptionService } from "~/lib/utils.server"; + +import { type Schema_File, Schema_FileToken } from "~/types/schema"; + +import config from "config"; + +/** + * Create a token to download a file + * @param file - File data + * @param expiredIn - Token expiration time in milliseconds (default to configuration) + */ +export async function CreateFileToken( + file: z.infer, + expiredIn: number = 3600 * 1000 * config.apiConfig.temporaryTokenDuration, +): Promise> { + const tokenObject = { + id: file.encryptedId, + exp: Date.now() + expiredIn, + iat: Date.now(), + }; + const parsedTokenObject = Schema_FileToken.safeParse(tokenObject); + if (!parsedTokenObject.success) + return { + success: false, + message: "Failed to create token", + error: parsedTokenObject.error.message, + }; + + const token = await encryptionService.encrypt(JSON.stringify(parsedTokenObject.data)); + + return { + success: true, + message: "Token created", + data: token, + }; +} + +/** + * Validate a file token and return the token data + * @param token - The token to validate + */ +export async function ValidateFileToken( + token: string, +): Promise>> { + const decryptedToken = await encryptionService.decrypt(token); + const parsedToken = Schema_FileToken.safeParse(JSON.parse(decryptedToken)); + if (!parsedToken.success) + return { + success: false, + message: "Failed to validate token", + error: parsedToken.error.message, + }; + const currentTime = Date.now(); + if (parsedToken.data.exp < currentTime) + return { + success: false, + message: "Token expired", + error: "Download URL expired", + }; + + return { + success: true, + message: "Token validated", + data: parsedToken.data, + }; +} diff --git a/src/app/[...rest]/deploy/index.tsx b/src/app/[...rest]/deploy/index.tsx deleted file mode 100644 index 71bbdbf..0000000 --- a/src/app/[...rest]/deploy/index.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { icons } from "lucide-react"; -import { useMemo } from "react"; -import { getting_started, migration, new_user, shared_drive } from "~/data/docs"; - -import { Icon, Markdown } from "~/components/Global"; -import { ConfigurationForm } from "~/components/Guide/Configuration"; -import { ThemeForm } from "~/components/Guide/Theme"; -import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Separator } from "~/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; - -import { cn } from "~/utils/cn"; - -const Sections = ["start", "new-user", "shared-drive", "migrating", "config", "theme"] as const; -type Section = (typeof Sections)[number]; -type SectionItem = { - id: Section; - title: string; - icon: keyof typeof icons; -}; - -export default function DeployGuidePage() { - const sectionMenu = useMemo( - () => [ - { - id: "new-user", - title: "New User Guide", - icon: "UserPlus", - }, - { - id: "migrating", - title: "Migrating from v1", - icon: "GitBranch", - }, - { - id: "shared-drive", - title: "Shared Drive Guide", - icon: "Database", - }, - ], - [], - ); - - return ( -
- -
- -
- About this guide - -

{`If you can see this guide on your deployment. -Please set the \`showDeployGuide\` to false on the config files. Since it will take up the \`deploy\` route.`}

-
-
-
-
- - - - Deployment Guide - - - - - - - - - - {sectionMenu.map((item) => ( - - {item.title} - - ))} - - - - - - New User Guide - - - - - - - - - - - Shared Drive Guide - - - - - - - - - - - Migrating from v1 - - - - - - - - - - - - -
- -
- Heads up! - The value of the form will be reset when you switch tabs. -
-
-
- - - - - Configuration - - - Theme Customization - - - - - - - - - - -
- ); -} diff --git a/src/app/[...rest]/page.tsx b/src/app/[...rest]/page.tsx index 4081c4d..5b2f3c9 100644 --- a/src/app/[...rest]/page.tsx +++ b/src/app/[...rest]/page.tsx @@ -1,54 +1,49 @@ -import { Metadata, ResolvedMetadata } from "next"; +import { type Metadata, type ResolvedMetadata } from "next"; import { notFound } from "next/navigation"; -import { z } from "zod"; -import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer"; -import { Password } from "~/components/Layout"; -import { PreviewLayout } from "~/components/Preview"; +import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer"; +import { ErrorComponent, Password } from "~/components/layout"; +import { PreviewLayout } from "~/components/preview"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Separator } from "~/components/ui/separator"; -import { cn } from "~/utils/cn"; -import { decryptData } from "~/utils/encryptionHelper"; -import gdrive from "~/utils/gdriveInstance"; -import { getFileType } from "~/utils/previewHelper"; +import { getFileType } from "~/lib/previewHelper"; +import { formatPathToBreadcrumb } from "~/lib/utils"; -import { Schema_File } from "~/types/schema"; - -import { CheckPassword, CheckPaths, GetBanner, GetFile, GetFiles, GetReadme } from "actions"; -import config from "config"; - -import DeployGuidePage from "./deploy"; +import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files"; +import { CheckPagePassword } from "~/actions/password"; +import { ValidatePaths } from "~/actions/paths"; +import { CreateFileToken } from "~/actions/token"; export const revalidate = 60; export const dynamic = "force-dynamic"; type Props = { - params: { + params: Promise<{ rest: string[]; - }; + }>; }; -export async function generateMetadata({ params: { rest } }: Props, parent: ResolvedMetadata): Promise { - if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return { title: "Deploy Guide" }; +export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise { + const { rest } = await params; - const paths = await CheckPaths(rest); + const paths = await ValidatePaths(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 currentPath = paths.data.pop(); + if (!currentPath?.id) return { title: "Not Found" }; - const banner = await GetBanner(encryptedId); + const banner = await GetBanner(currentPath.id); return { - title: data.name, - description: data.mimeType?.includes("folder") ? `Browse ${data.name} files` : `View ${data.name}`, + title: decodeURIComponent(currentPath.path), + description: currentPath.mimeType.includes("folder") + ? `Browse ${currentPath.path} files` + : `View ${currentPath.path}`, openGraph: { - images: banner + images: banner.success ? [ { - url: `/api/og/${banner}`, + url: `/api/og/${banner.data}`, width: 1200, height: 630, }, @@ -57,99 +52,96 @@ export async function generateMetadata({ params: { rest } }: Props, parent: Reso }, }; } +export default async function RestPage({ params }: Props) { + const { rest } = await params; -export default async function RestPage({ params: { rest } }: Props) { - if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return ; - - const paths = await CheckPaths(rest); + const paths = await ValidatePaths(rest); if (!paths.success) notFound(); - const unlocked = await CheckPassword(paths.data); + const unlocked = await CheckPagePassword(paths.data); if (!unlocked.success) { - if (!unlocked.path) - throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`); return ( ); } - const encryptedId = paths.data.pop()?.id; - if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page."); + const currentPath = paths.data[paths.data.length - 1]; + if (!currentPath) return ; - const promise = []; - - const { data: file } = await gdrive.files.get({ - fileId: await decryptData(encryptedId), - fields: "mimeType, fileExtension", - supportsAllDrives: config.apiConfig.isTeamDrive, - }); - 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]; - } - }); - - return ( -
- ({ - label: decodeURIComponent(item), - href: index === array.length - 1 ? undefined : `${item}`, - }))} - /> + const Layout: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => ( +
+
- {!("files" in data) ? ( - - ) : ( - <> - - -
- Browse files - -
- -
- - - -
- - )} + {children}
- - {readme && ( - - )}
); + + if (currentPath.mimeType.includes("folder")) { + const [data, readme] = await Promise.all([ListFiles({ id: currentPath.id }), GetReadme(currentPath.id)]); + if (!data.success) return ; + if (!readme.success) return ; + + return ( + + + +
+ Browse files + +
+
+ + + + +
+ + {readme.data && ( + + )} +
+ ); + } + + const file = await GetFile(currentPath.id); + if (!file.success) { + if (file.error === "NotFound") notFound(); + return ; + } + if (!file.data) return ; + const token = await CreateFileToken(file.data); + if (!token.success) return ; + + return ( + + + + ); } diff --git a/src/app/actions.ts b/src/app/actions.ts deleted file mode 100644 index 6ea365f..0000000 --- a/src/app/actions.ts +++ /dev/null @@ -1,821 +0,0 @@ -"use server"; - -import crypto from "crypto"; -import { revalidatePath } from "next/cache"; -import { cookies } from "next/headers"; -import { z } from "zod"; - -import { decryptData, encryptData } from "~/utils/encryptionHelper"; -import gdrive, { gdriveNoCache } from "~/utils/gdriveInstance"; - -import { Constant } from "~/types/constant"; -import { Schema_File } from "~/types/schema"; - -import config from "config"; - -export async function CheckSitePassword(): Promise<{ - success: boolean; - message?: string; -}> { - try { - // Skip if the index is public - if (!config.siteConfig.privateIndex) - return { - success: true, - }; - if (!process.env.SITE_PASSWORD) - throw new Error( - "Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file", - ); - - const store = cookies(); - if (!store.has(Constant.cookies_SitePassword)) - return { - success: false, - }; - const password = store.get(Constant.cookies_SitePassword)?.value || ""; - const decryptedPassword = await decryptData(password); - if (decryptedPassword !== process.env.SITE_PASSWORD) - throw new Error("Saved password is incorrect, please re-enter the password"); - - return { - success: true, - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } -} -export async function SetSitePassword(password: string): Promise<{ - success: boolean; - message: string; -}> { - try { - const store = cookies(); - const encryptedPassword = await encryptData(password); - store.set(Constant.cookies_SitePassword, encryptedPassword, { - path: "/", - secure: true, - sameSite: "strict", - httpOnly: true, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), - maxAge: 1000 * 60 * 60 * 24 * 365, - }); - revalidatePath("/", "layout"); - - return { - success: true, - message: "Password set", - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } -} - -export async function ClearPassword(): Promise<{ - success: boolean; - message: string; -}> { - try { - const store = cookies(); - if (store.has(Constant.cookies_FolderPassword)) { - store.delete(Constant.cookies_FolderPassword); - } - if (store.has(Constant.cookies_SitePassword)) { - store.delete(Constant.cookies_SitePassword); - } - return { - success: true, - message: "Password cleared", - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } -} - -export async function CheckPaths(paths: string[]): Promise< - | { - success: false; - message?: string; - } - | { - success: true; - data: { path: string; id: string }[]; - message?: string; - } -> { - try { - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - - 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)", - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }) - .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); - const dataWithIndex = data.map((item, index) => { - return { - ...item, - index: index, - }; - }); - const filteredPaths: { path: string; data: { id: string; parents: string; mimeType: string }[] }[] = []; - - for (const item of dataWithIndex) { - if (!valid) break; - - // Loop through data - // If the current index is 0, check if the parents is the root folder or shared drive - // If the current index is not 0, check if the parents is the previous data id from the filteredPaths - - // Use filteredPaths because it will only contain the valid path - // So if filteredPaths length is not equal to paths length, then it means there is an invalid path - for (const path of item.data || []) { - if (item.index === 0) { - if (path.parents === decryptedRootId || path.parents === decryptedSharedDrive) { - filteredPaths.push({ - path: item.path!, - data: [{ id: path.id!, parents: path.parents!, mimeType: path.mimeType! }], - }); - break; - } - } else { - if (path.parents === filteredPaths[item.index - 1]?.data?.[0].id) { - filteredPaths.push({ - path: item.path!, - data: [{ id: path.id!, parents: path.parents!, mimeType: path.mimeType! }], - }); - break; - } - } - } - } - if (filteredPaths.length !== paths.length) { - valid = false; - invalidPath = dataWithIndex[filteredPaths.length]?.path; - } - - if (!valid) throw new Error(`Invalid path: ${invalidPath}`); - - const ids: { path: string; id: string }[] = []; - for (const item of filteredPaths) { - 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)); - } - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - 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 gdriveNoCache.files - .list({ - q: query.join(" and "), - fields: "files(id, name, mimeType, parents)", - pageSize: 1000, - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }) - .catch((error) => { - console.error(error); - throw new Error("Failed to get password file"); - }); - - // 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`, - message: `Please enter password for '${currentFolder.path}'`, - path: currentFolder.id, - }; - - const savedPassword = await decryptData(cookiesValue[currentFolder.id]); - const { data: passwordFile } = await gdrive.files.get( - { - fileId: protectedFolder.id as string, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - responseType: "text", - }, - ); - if (savedPassword !== passwordFile) - throw { - message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`, - path: currentFolder.id, - }; - - return { - success: true, - }; - } catch (error) { - if (error instanceof Error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } else { - const e = error as { message: string; path: string }; - console.error(e.message); - return { - success: false, - message: e.message, - path: e.path, - }; - } - } -} -export async function SetPassword( - path: string, - password: string, -): Promise<{ - success: boolean; - message: string; -}> { - try { - const store = cookies(); - const cookiesValue = JSON.parse(store.get(Constant.cookies_FolderPassword)?.value || "{}"); - const updatedValue = { - ...cookiesValue, - [path]: await encryptData(password), - }; - store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), { - path: "/", - secure: true, - sameSite: "strict", - httpOnly: true, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), - maxAge: 1000 * 60 * 60 * 24 * 365, - }); - revalidatePath("/", "layout"); - - return { - success: true, - message: "Password set", - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } -} - -export async function GetFiles({ id, pageToken }: { id?: string; pageToken?: string }): Promise<{ - files: z.infer[]; - nextPageToken?: string; -}> { - try { - let decryptedId: string | undefined; - if (id) decryptedId = await decryptData(id); - else decryptedId = await decryptData(config.apiConfig.rootFolder); - - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - - const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and "); - const query: string = [...config.apiConfig.defaultQuery, `'${decryptedId}' in parents`, `${filterName}`].join( - " and ", - ); - - const data = await gdrive.files.list({ - q: query, - fields: `files(${config.apiConfig.defaultField}), nextPageToken`, - orderBy: config.apiConfig.defaultOrder, - pageSize: config.apiConfig.itemsPerPage, - pageToken: pageToken, - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }); - - const encryptedData: z.infer[] = []; - if (!data.data.files?.length) return { files: [], nextPageToken: undefined }; - for (const file of data.data.files) { - encryptedData.push({ - mimeType: file.mimeType as string, - encryptedId: await encryptData(file.id as string), - name: file.name as string, - trashed: (file.trashed as boolean) ?? false, - modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(), - fileExtension: file.fileExtension || undefined, - encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined, - size: file.size ? Number(file.size) : undefined, - thumbnailLink: file.thumbnailLink || undefined, - imageMediaMetadata: file.imageMediaMetadata - ? { - width: Number(file.imageMediaMetadata.width), - height: Number(file.imageMediaMetadata.height), - rotation: Number(file.imageMediaMetadata.rotation || 0), - } - : undefined, - videoMediaMetadata: file.videoMediaMetadata - ? { - width: Number(file.videoMediaMetadata.width), - height: Number(file.videoMediaMetadata.height), - durationMillis: Number(file.videoMediaMetadata.durationMillis), - } - : undefined, - }); - } - const parsedContent = Schema_File.array().parse(encryptedData); - - return { - files: parsedContent, - nextPageToken: data.data.nextPageToken ?? undefined, - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function GetFile(encryptedId: string): Promise> { - try { - const decryptedId = await decryptData(encryptedId); - const { data: file } = await gdrive.files.get({ - fileId: decryptedId, - fields: config.apiConfig.defaultField, - supportsAllDrives: config.apiConfig.isTeamDrive, - }); - - const payload: z.infer = { - encryptedId, - mimeType: file.mimeType as string, - name: file.name as string, - trashed: (file.trashed as boolean) ?? false, - modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(), - fileExtension: file.fileExtension || undefined, - encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined, - size: file.size ? Number(file.size) : undefined, - thumbnailLink: file.thumbnailLink || undefined, - imageMediaMetadata: file.imageMediaMetadata - ? { - width: Number(file.imageMediaMetadata.width), - height: Number(file.imageMediaMetadata.height), - rotation: Number(file.imageMediaMetadata.rotation || 0), - } - : undefined, - videoMediaMetadata: file.videoMediaMetadata - ? { - width: Number(file.videoMediaMetadata.width), - height: Number(file.videoMediaMetadata.height), - durationMillis: Number(file.videoMediaMetadata.durationMillis), - } - : undefined, - }; - const parsedContent = Schema_File.parse(payload); - - return parsedContent; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function SearchFile( - keyword: string, - nextPageToken?: string, -): Promise<{ - files: z.infer[]; - nextPageToken?: string; -}> { - try { - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - - 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, - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }); - const encryptedData: z.infer[] = []; - if (!data.data.files?.length) return { files: [], nextPageToken: undefined }; - for (const file of data.data.files) { - encryptedData.push({ - mimeType: file.mimeType as string, - encryptedId: await encryptData(file.id as string), - name: file.name as string, - trashed: (file.trashed as boolean) ?? false, - modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(), - fileExtension: file.fileExtension || undefined, - encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined, - size: file.size ? Number(file.size) : undefined, - thumbnailLink: file.thumbnailLink || undefined, - imageMediaMetadata: file.imageMediaMetadata - ? { - width: Number(file.imageMediaMetadata.width), - height: Number(file.imageMediaMetadata.height), - rotation: Number(file.imageMediaMetadata.rotation || 0), - } - : undefined, - videoMediaMetadata: file.videoMediaMetadata - ? { - width: Number(file.videoMediaMetadata.width), - height: Number(file.videoMediaMetadata.height), - durationMillis: Number(file.videoMediaMetadata.durationMillis), - } - : undefined, - }); - } - const parsedContent = Schema_File.array().parse(encryptedData); - - return { - files: parsedContent, - nextPageToken: data.data.nextPageToken ?? undefined, - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function RedirectSearchFile(encryptedId: string): Promise { - try { - const { data } = await gdrive.files.get({ - fileId: await decryptData(encryptedId), - fields: "id, name, parents", - supportsAllDrives: config.apiConfig.isTeamDrive, - }); - - const paths: string[] = [data.name as string]; - let parentId = data.parents?.[0]; - const decryptedRootId = await decryptData(config.apiConfig.rootFolder); - while (parentId) { - if (parentId === decryptedRootId) break; - const { data: parent } = await gdrive.files.get({ - fileId: parentId, - fields: "id, name, parents", - supportsAllDrives: config.apiConfig.isTeamDrive, - }); - paths.unshift(parent.name as string); - parentId = parent.parents?.[0]; - } - - return paths.join("/"); - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} - -export async function GetContent(encryptedId: string): Promise { - try { - const decryptedId = await decryptData(encryptedId); - const { data: file } = await gdrive.files.get( - { - fileId: decryptedId, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - responseType: "text", - }, - ); - - if (typeof file !== "string") throw new Error("It seems the file is not a text file"); - - return file as string; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function GetReadme(encryptedId: string | undefined): Promise { - try { - let decryptedId; - if (encryptedId) decryptedId = await decryptData(encryptedId); - else decryptedId = await decryptData(config.apiConfig.rootFolder); - - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - - const query: string[] = [ - "trashed = false", - "(not mimeType contains 'folder')", - `name = '${config.apiConfig.specialFile.readme}'`, - `'${decryptedId}' in parents`, - ]; - const { data } = await gdrive.files.list({ - q: query.join(" and "), - fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`, - orderBy: config.apiConfig.defaultOrder, - pageSize: config.apiConfig.itemsPerPage, - pageToken: undefined, - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }); - - if (!data.files?.length) return null; - - let file; - - if (data.files.length === 1) { - file = data.files[0]; - } else { - // Check by mimetype, use google-apps.shortcut last - file = data.files.find((item) => item.mimeType === "text/markdown"); - if (!file) { - file = data.files.find((item) => item.mimeType === "application/vnd.google-apps.shortcut"); - } - } - - if (!file) return null; - if ( - file.mimeType !== "text/markdown" && - file.mimeType !== "application/vnd.google-apps.shortcut" && - file.mimeType !== "text/plain" - ) - return null; - - if (file.mimeType === "application/vnd.google-apps.shortcut") { - const { data: shortcutData } = (await gdrive.files.get({ - fileId: file.id as string, - fields: "shortcutDetails", - supportsAllDrives: config.apiConfig.isTeamDrive, - })) as { - data: { shortcutDetails: { targetId: string; targetMimeType: string } }; - }; - if (!shortcutData.shortcutDetails.targetId) return null; - if (shortcutData.shortcutDetails.targetMimeType !== "text/markdown") return null; - - const { data: content } = await gdrive.files.get( - { - fileId: shortcutData.shortcutDetails.targetId, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - responseType: "text", - }, - ); - - return content as string; - } - - const { data: content } = await gdrive.files.get( - { - fileId: file.id as string, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - responseType: "text", - }, - ); - - return content as string; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function GetBanner(encryptedId: string | undefined): Promise { - try { - let decryptedId; - if (encryptedId) decryptedId = await decryptData(encryptedId); - else decryptedId = await decryptData(config.apiConfig.rootFolder); - - let decryptedSharedDrive; - if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) { - decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive); - } - - 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, - ...(decryptedSharedDrive && { - supportsAllDrives: true, - includeItemsFromAllDrives: true, - driveId: decryptedSharedDrive, - corpora: "drive", - }), - }); - if (!data.files?.length) return null; - - return await encryptData(data.files[0].id as string); - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} - -export async function GetWebContent(encrypted: string): Promise { - try { - return await decryptData(encrypted); - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function CreateDownloadToken(duration: number = config.apiConfig.temporaryTokenDuration): Promise { - try { - const data = { - expiredAt: Date.now() + duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration, - }; - const token = await encryptData(JSON.stringify(data)); - return token; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function CheckDownloadToken(token: string): Promise<{ - success: boolean; - message?: string; -}> { - try { - const decryptToken = JSON.parse(await decryptData(token)); - if (decryptToken.expiredAt < Date.now()) { - return { - success: false, - message: "Download token expired, please click the download button again to get a new token", - }; - } - - return { - success: true, - }; - } catch (error) { - const e = error as Error; - console.error(e.message); - return { - success: false, - message: e.message, - }; - } -} - -export async function GenerateAESKey(): Promise { - try { - const key = crypto.randomBytes(8).toString("hex"); - return key; - } catch (error) { - const e = error as Error; - console.error(e.message); - throw new Error(e.message); - } -} -export async function VerifyAESKey(data: string, key: string): Promise { - try { - let paddedKey; - if (key.length < 16) { - paddedKey = key.padEnd(16, "0"); - } else if (key.length > 16) { - paddedKey = key.slice(0, 16); - } else { - paddedKey = key; - } - - const encrypt = await encryptData(data, paddedKey); - const decrypt = await decryptData(encrypt, paddedKey); - return !!encrypt && !!decrypt; - } catch (error) { - return false; - } -} diff --git a/src/app/api/download/[...rest]/route.ts b/src/app/api/download/[...rest]/route.ts new file mode 100644 index 0000000..bc6fe20 --- /dev/null +++ b/src/app/api/download/[...rest]/route.ts @@ -0,0 +1,145 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { encryptionService, gdrive } from "~/lib/utils.server"; + +import { GetFile } from "~/actions/files"; +import { CheckIndexPassword, CheckPagePassword } from "~/actions/password"; +import { ValidatePaths } from "~/actions/paths"; +import { ValidateFileToken } from "~/actions/token"; + +import config from "config"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ rest: string[] }> }) { + const { rest } = await params; + const sp = new URL(request.nextUrl).searchParams; + const forceRedirect = sp.get("redirect") === "1"; + const token = sp.get("token"); + const paths = rest.map((path) => { + if (path.startsWith("/")) return decodeURIComponent(path.slice(1)); + return decodeURIComponent(path); + }); + + try { + if (!config.apiConfig.allowDownloadProtectedFile) { + if (!token) { + throw new Error("[401] Token not found", { + cause: "This endpoint requires a token to download the file", + }); + } + + const validateToken = await ValidateFileToken(token); + if (!validateToken.success) { + throw new Error(`[401] ${validateToken.message}`, { + cause: validateToken.error, + }); + } + } + + const validatedPaths = await ValidatePaths(paths); + if (!validatedPaths.success) { + throw new Error(`[404] ${validatedPaths.message}`, { + cause: validatedPaths.error, + }); + } + + const currentFile = validatedPaths.data.pop(); + if (!currentFile) { + throw new Error("[404] File not found", { + cause: "Failed to get current file", + }); + } + + if (config.siteConfig.privateIndex && !config.apiConfig.allowDownloadProtectedFile) { + const [indexUnlocked, pageUnlocked] = await Promise.all([ + CheckIndexPassword(), + CheckPagePassword(validatedPaths.data), + ]); + if (!indexUnlocked.success) { + throw new Error(`[401] ${indexUnlocked.message}`, { + cause: indexUnlocked.error, + }); + } + if (!pageUnlocked.success) { + throw new Error(`[401] ${pageUnlocked.message}`, { + cause: pageUnlocked.error, + }); + } + } + + const file = await GetFile(currentFile.id); + if (!file.success) { + throw new Error(`[404] ${file.message}`, { + cause: file.error, + }); + } + if (!file.data?.encryptedWebContentLink) { + throw new Error("[500] No download link found", { + cause: "No download link found", + }); + } + + const fileSize = Number(file.data?.size ?? 0); + if ((config.apiConfig.maxFileSize > 0 && fileSize > config.apiConfig.maxFileSize) || forceRedirect) { + const decryptedContentUrl = await encryptionService.decrypt(file.data.encryptedWebContentLink); + const contentUrl = new URL(decryptedContentUrl); + contentUrl.searchParams.set("confirm", "1"); + return new NextResponse(null, { + status: 302, + headers: { + Location: contentUrl.toString(), + }, + }); + } + + const content = await gdrive.files.get( + { + fileId: await encryptionService.decrypt(file.data.encryptedId), + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + acknowledgeAbuse: true, + }, + { + responseType: "stream", + }, + ); + const fileBuffer = await new Promise((res, rej) => { + const chunks: Buffer[] = []; + content.data.on("data", (chunk) => { + chunks.push(Buffer.from(chunk as ArrayBufferLike)); + }); + content.data.on("end", () => { + res(Buffer.concat(chunks)); + }); + content.data.on("error", (err) => { + rej(err); + }); + }); + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": file.data.mimeType, + "Content-Length": fileBuffer.length.toString(), + "Content-Disposition": `attachment; filename="${file.data.name}"`, + "Cache-Control": config.cacheControl, + }, + }); + } catch (error) { + const e = error as Error; + const message = e.message.replace(/\[.*\]/, "").trim(); + const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500; + + return NextResponse.json( + { + scope: "api/download", + message, + cause: e.cause ?? "Unknown", + }, + { + status: Number(status), + }, + ); + } +} diff --git a/src/app/api/download/[encryptedId]/route.ts b/src/app/api/download/[encryptedId]/route.ts deleted file mode 100644 index 39956d5..0000000 --- a/src/app/api/download/[encryptedId]/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -import { decryptData } from "~/utils/encryptionHelper"; -import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance"; - -import { CheckDownloadToken, CheckPassword, CheckPaths, CheckSitePassword, RedirectSearchFile } from "actions"; -import config from "config"; - -export const dynamic = "force-dynamic"; - -export async function GET( - request: NextRequest, - { - params: { encryptedId }, - }: { - params: { - encryptedId: string; - }; - }, -) { - try { - const sp = new URL(request.nextUrl).searchParams; - const token = sp.get("token"); - if (!token) throw new Error("Token not found"); - - const tokenValidity = await CheckDownloadToken(token); - if (!tokenValidity.success) throw new Error(tokenValidity.message); - - if (config.siteConfig.privateIndex && !config.apiConfig.allowDownloadProtectedFile) { - 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, size, fileExtension, webContentLink", - supportsAllDrives: config.apiConfig.isTeamDrive, - }); - - const [fileMeta, filePaths] = await Promise.all([_fileMeta, _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) { - const contentUrl = new URL(fileMeta.data.webContentLink); - contentUrl.searchParams.set("confirm", "1"); - return NextResponse.redirect(contentUrl, { - status: 302, - }); - } - - const fileContent = await gdrive.files.get( - { - fileId: decryptedId, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - responseType: "stream", - }, - ); - - 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-Length": fileBuffer.length.toString(), - "Content-Disposition": `attachment; filename="${encodeURIComponent( - fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`, - )}"`, - "Cache-Control": config.cacheControl, - }, - }); - } catch (error) { - const e = error as Error; - console.error(e.message); - return new NextResponse(e.message, { - status: 500, - }); - } -} diff --git a/src/app/api/internal/encrypt/route.ts b/src/app/api/internal/encrypt/route.ts index 3e65fe2..897bbec 100644 --- a/src/app/api/internal/encrypt/route.ts +++ b/src/app/api/internal/encrypt/route.ts @@ -1,6 +1,6 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; -import { encryptData } from "~/utils/encryptionHelper"; +import { encryptionService } from "~/lib/utils.server"; export const dynamic = "force-dynamic"; @@ -11,13 +11,19 @@ export async function GET(request: NextRequest) { const key = sp.get("key"); if (!query) return new NextResponse("Add query parameter 'q' with the value to encrypt", { status: 400 }); - const encrypted = await encryptData(query, key || process.env.ENCRYPTION_KEY); + if (process.env.NODE_ENV !== "development" && !key) { + throw new Error("Key is required in production environment"); + } + + const encrypted = await encryptionService.encrypt(query, key ?? undefined); + const decrypted = await encryptionService.decrypt(encrypted, key ?? undefined); return NextResponse.json( { message: key ? "Encrypted with provided key" : "Encrypted with environment key", - value: encrypted, - key, + encryptedValue: encrypted, + decryptedValue: decrypted, + key: key ?? process.env.ENCRYPTION_KEY, }, { status: 200 }, ); diff --git a/src/app/api/og/[encryptedId]/route.ts b/src/app/api/og/[encryptedId]/route.ts index 61f5ddf..cbcb3b1 100644 --- a/src/app/api/og/[encryptedId]/route.ts +++ b/src/app/api/og/[encryptedId]/route.ts @@ -1,19 +1,22 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; -import { decryptData } from "~/utils/encryptionHelper"; -import gdrive from "~/utils/gdriveInstance"; +import { encryptionService, gdrive } from "~/lib/utils.server"; import config from "config"; +export const dynamic = "force-dynamic"; + type Props = { - params: { + params: Promise<{ encryptedId: string; - }; + }>; }; -export async function GET(request: NextRequest, { params: { encryptedId } }: Props) { +export async function GET(request: NextRequest, { params }: Props) { + const { encryptedId } = await params; + try { - const decryptedId = await decryptData(encryptedId); + const decryptedId = await encryptionService.decrypt(encryptedId); const { data } = await gdrive.files.get({ fileId: decryptedId, fields: "id, name, mimeType, webContentLink", @@ -29,7 +32,7 @@ export async function GET(request: NextRequest, { params: { encryptedId } }: Pro return new NextResponse(bufferData, { headers: { "Cache-Control": "public, max-age=31536000, immutable", - "Content-Type": data.mimeType || "application/octet-stream", + "Content-Type": data.mimeType ?? "application/octet-stream", "Content-Length": bufferData.length.toString(), "Content-Disposition": `inline; filename="${data.name}"`, }, diff --git a/src/app/api/preview/[encryptedId]/route.ts b/src/app/api/preview/[encryptedId]/route.ts new file mode 100644 index 0000000..03324f7 --- /dev/null +++ b/src/app/api/preview/[encryptedId]/route.ts @@ -0,0 +1,120 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { IS_DEV } from "~/constant"; + +import { encryptionService, gdriveNoCache } from "~/lib/utils.server"; + +import { GetFile } from "~/actions/files"; + +import config from "config"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { + params, + }: { + params: Promise<{ + encryptedId: string; + }>; + }, +) { + const { encryptedId } = await params; + const sp = new URL(request.url).searchParams; + const isInline = sp.get("inline") === "1"; + const isFull = sp.get("full") === "1"; + const origin = request.headers.get("Origin") ?? request.headers.get("Referer") ?? request.headers.get("Host") ?? null; + + try { + if (!origin) { + throw new Error("[500] Invalid request", { + cause: "Request headers is invalid", + }); + } + + // // Only allow if the request is from the same domain or the referer is the same domain + if (!IS_DEV && !origin?.toLowerCase().includes(config.basePath.toLowerCase())) { + throw new Error("[403] Unauthorized", { + cause: "Request is not allowed", + }); + } + + const decryptedId = await encryptionService.decrypt(encryptedId); + const file = await GetFile(encryptedId); + if (!file.success) { + throw new Error(`[404] ${file.message}`, { + cause: file.error, + }); + } + if (!file.data?.encryptedWebContentLink) { + throw new Error("[500] No download link found", { + cause: "No download link found", + }); + } + + const fileSize = Number(file.data.size ?? 0); + const isFullyLoaded = isFull ? true : Number(request.headers.get("Range")?.split("-")[1] ?? 0) === fileSize - 1; + + if (config.apiConfig.streamMaxSize && fileSize > config.apiConfig.streamMaxSize) { + throw new Error("[400] File is too large to stream", { + cause: "File size is larger than the maximum allowed size, please download the file instead", + }); + } + + const ranges = request.headers.get("Range") ?? "bytes=0-"; + const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time + let rangeStart = 0; + let rangeEnd = Math.min(chunkSize, fileSize - 1); + const rangeSize = /bytes=(\d+)-(\d+)?/.exec(ranges); + if (rangeSize) { + rangeStart = Number(rangeSize?.[1] ?? 0); + + if (isFullyLoaded) { + rangeEnd = fileSize - 1; + } else { + rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1); + } + } + + const content = await gdriveNoCache.files.get( + { + fileId: decryptedId, + alt: "media", + supportsAllDrives: config.apiConfig.isTeamDrive, + acknowledgeAbuse: true, + }, + { + responseType: "stream", + headers: { + "Accept-Ranges": "bytes", + "Range": `bytes=${rangeStart}-${rangeEnd}`, + }, + }, + ); + + const headers = new Headers(content.headers); + if (isInline) { + headers.set("Content-Disposition", `inline; filename="${file.data.name}"`); + } + + return new NextResponse(content.data as unknown as BodyInit, { + status: isFullyLoaded ? 200 : content.status, + headers: headers, + }); + } catch (error) { + const e = error as Error; + const message = e.message.replace(/\[.*\]/, "").trim(); + const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500; + + return NextResponse.json( + { + scope: "api/preview", + message, + cause: e.cause ?? "Unknown", + }, + { + status: Number(status), + }, + ); + } +} diff --git a/src/app/api/raw/[...rest]/route.ts b/src/app/api/raw/[...rest]/route.ts index 2481ddb..03dee29 100644 --- a/src/app/api/raw/[...rest]/route.ts +++ b/src/app/api/raw/[...rest]/route.ts @@ -1,49 +1,73 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; -import { decryptData } from "~/utils/encryptionHelper"; +import { encryptionService } from "~/lib/utils.server"; -import { CheckPassword, CheckPaths, GetFile } from "actions"; -import config from "config"; +import { GetFile } from "~/actions/files"; +import { ValidatePaths } from "~/actions/paths"; export const dynamic = "force-dynamic"; -export async function GET(request: NextRequest, { params: { rest } }: { params: { rest: string[] } }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ rest: string[] }> }) { + const { rest } = await params; + const ALLOWED_TYPES = ["image/*", "video/*", "audio/*"]; // Based from mime types + const paths = rest.map((path) => { + if (path.startsWith("/")) return decodeURIComponent(path.slice(1)); + return decodeURIComponent(path); + }); + 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 validatedPaths = await ValidatePaths(paths); + if (!validatedPaths.success) { + throw new Error(`[404] ${validatedPaths.message}`, { + cause: validatedPaths.error, + }); } - 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 currentFile = validatedPaths.data.pop(); + if (!currentFile) { + throw new Error("[404] File not found", { + cause: "Failed to get current file", + }); + } + if (ALLOWED_TYPES.every((type) => !new RegExp(type.replace("*", ".*")).test(currentFile.mimeType))) { + throw new Error("[400] Invalid file type", { + cause: "Raw link only available for video, image, and audio files", + }); + } - 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 fileMeta = await GetFile(currentFile.id); + if (!fileMeta.success) { + throw new Error(`[500] ${fileMeta.message}`, { + cause: fileMeta.error, + }); + } + if (!fileMeta.data?.encryptedWebContentLink) { + throw new Error("[500] No download link found", { + cause: "No download link found", + }); + } - const decryptedWebContent = await decryptData(data.encryptedWebContentLink); + const decryptedLink = await encryptionService.decrypt(fileMeta.data.encryptedWebContentLink); return new NextResponse(null, { status: 302, headers: { - Location: decryptedWebContent, + Location: decryptedLink, }, }); } catch (error) { const e = error as Error; - console.error(e.message); - return new NextResponse(e.message, { - status: 500, - }); + const message = e.message.replace(/\[.*\]/, "").trim(); + const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500; + + return NextResponse.json( + { + scope: "api/raw", + message, + cause: e.cause ?? "Unknown", + }, + { + status: Number(status), + }, + ); } } diff --git a/src/app/api/stream/[encryptedId]/route.ts b/src/app/api/stream/[encryptedId]/route.ts deleted file mode 100644 index 46c85e6..0000000 --- a/src/app/api/stream/[encryptedId]/route.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -import { decryptData } from "~/utils/encryptionHelper"; -import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance"; -import { isDev } from "~/utils/isDev"; - -import { CheckDownloadToken, CheckPassword, CheckPaths, RedirectSearchFile } from "actions"; -import config from "config"; - -export const dynamic = "force-dynamic"; - -export async function GET( - request: NextRequest, - { - params: { encryptedId }, - }: { - params: { - encryptedId: string; - }; - }, -) { - try { - const sp = new URL(request.nextUrl).searchParams; - const token = sp.get("token"); - if (!token) throw new Error("Token not found"); - - // Only allow if the request is from the same domain or the referer is the same domain - if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) { - throw new Error("Invalid request"); - } - - const tokenValidity = await CheckDownloadToken(token); - if (!tokenValidity.success) throw new Error(tokenValidity.message); - - const decryptedId = await decryptData(encryptedId); - - const _filePaths = RedirectSearchFile(encryptedId); - const _fileMeta = gdrive.files.get( - { - fileId: decryptedId, - fields: "id, name, mimeType, size, fileExtension, webContentLink", - supportsAllDrives: config.apiConfig.isTeamDrive, - }, - { - headers: { - "Accept-Ranges": "bytes", - "Range": request.headers.get("Range") || `bytes=0-${1024 * 1024 - 1}`, - }, - }, - ); - - const [filePaths, fileMeta] = await Promise.all([_filePaths, _fileMeta]); - - const isFull = Number(request.headers.get("Range")?.split("-")[1] || 0) === Number(fileMeta.data.size || "1") - 1; - - const fileSize = Number(fileMeta.data.size || 0); - if (!fileMeta.data.webContentLink) throw new Error("No download link found"); - - if (config.apiConfig.streamMaxSize && fileSize > config.apiConfig.streamMaxSize) { - throw new Error("File is too large to stream"); - } - - 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 ranges = request.headers.get("Range") || "bytes=0-"; - const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time - let rangeStart = 0; - let rangeEnd = Math.min(chunkSize, fileSize - 1); - const rangeRegex = /bytes=(\d+)-(\d+)?/; - const rangeSize = ranges.match(rangeRegex); - if (rangeSize) { - rangeStart = parseInt(rangeSize[1], 10); - - if (isFull) { - rangeEnd = fileSize - 1; - } else { - rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1); - } - } - - const contentRange = `bytes=${rangeStart}-${rangeEnd}/${fileSize}`; - const contentLength = rangeEnd ? rangeEnd - rangeStart + 1 : fileSize; - - const fileContent = await gdrive.files.get( - { - fileId: decryptedId, - alt: "media", - supportsAllDrives: config.apiConfig.isTeamDrive, - acknowledgeAbuse: true, - }, - { - responseType: "stream", - headers: { - "Accept-Ranges": "bytes", - "Range": `bytes=${rangeStart}-${rangeEnd}`, - }, - }, - ); - - const stream = fileContent.data as NodeJS.ReadableStream; - const fileRange = fileContent.headers["content-range"]; - const fileLength = fileContent.headers["content-length"]; - - const readable = new ReadableStream({ - start(controller) { - stream.on("data", (chunk) => { - controller.enqueue(chunk); - }); - stream.on("end", () => { - controller.close(); - }); - stream.on("error", (error) => { - controller.error(error); - }); - }, - }); - - return new NextResponse(readable, { - status: 206, - headers: { - "Content-Range": fileRange || contentRange, - "Content-Length": fileLength || contentLength.toString(), - "Content-Type": fileMeta.data.mimeType || "application/octet-stream", - "Accept-Ranges": "bytes", - }, - }); - } catch (error) { - const e = error as Error; - console.error(e.message); - return new NextResponse(e.message, { - status: 500, - }); - } -} diff --git a/src/app/api/thumb/[encryptedId]/route.ts b/src/app/api/thumb/[encryptedId]/route.ts index 0255c3d..1b7472b 100644 --- a/src/app/api/thumb/[encryptedId]/route.ts +++ b/src/app/api/thumb/[encryptedId]/route.ts @@ -1,24 +1,27 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { IS_DEV } from "~/constant"; -import { decryptData } from "~/utils/encryptionHelper"; -import { isDev } from "~/utils/isDev"; +import { encryptionService } from "~/lib/utils.server"; import config from "config"; +export const dynamic = "force-dynamic"; + type Props = { - params: { + params: Promise<{ encryptedId: string; - }; + }>; }; -export async function GET(request: NextRequest, { params: { encryptedId } }: Props) { +export async function GET(request: NextRequest, { params }: Props) { + const { encryptedId } = await params; try { const searchParams = new URL(request.nextUrl).searchParams; - const size = searchParams.get("size") || "512"; + const size = searchParams.get("size") ?? "512"; // Only allow if the request is from the same domain or the referer is the same domain - if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) { + if (!IS_DEV && !request.headers.get("Referer")?.includes(config.basePath)) { throw new Error("Invalid request"); } @@ -27,10 +30,7 @@ export async function GET(request: NextRequest, { params: { encryptedId } }: Pro throw new Error("Invalid size"); } - const defaultImage = NextResponse.redirect(new URL("/og.png", config.basePath), { - status: 302, - }); - const decryptedId = await decryptData(encryptedId); + const decryptedId = await encryptionService.decrypt(encryptedId); const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`; diff --git a/src/app/error.tsx b/src/app/error.tsx index 8ba565e..4ed2119 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,49 +1,12 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; - -import { Icon } from "~/components/Global"; -import { Button } from "~/components/ui/button"; - -import { cn } from "~/utils/cn"; - -export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { - const router = useRouter(); - useEffect(() => { - console.error(error); - }, [error]); +import ErrorComponent from "~/components/layout/Error"; +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset?: () => void }) { 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 deleted file mode 100644 index 75f52f7..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,123 +0,0 @@ -/* Version: ${pkg.version} */ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Shadcn/ui theme */ -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 89.8%; - /* --ring: 0 0% 3.9%; */ - --radius: 0.5rem; - } - - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 14.9%; - /* --ring: 0 0% 83.1%; */ - } - - div.preview * { - @apply !transition-none; - } -} - -html, -body { - margin: 0; - padding: 0; - box-sizing: border-box; -} -@layer base { - :root { - /* @apply text-[14px] tablet:text-[16px]; */ - @apply text-[100%]; - } - * { - @apply border-border; - /* @apply outline outline-1 outline-red-500; */ - } - body { - @apply bg-background text-foreground; - } - - ::-webkit-scrollbar { - @apply h-1.5 w-1.5; - } - ::-webkit-scrollbar-track { - @apply bg-primary/5; - } - ::-webkit-scrollbar-thumb { - @apply rounded-full bg-primary/25 hover:bg-primary/50; - } - - /* Typography */ - h1 { - @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; - } - h2 { - @apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0; - } - h3 { - @apply scroll-m-20 text-2xl font-semibold tracking-tight; - } - h4 { - @apply scroll-m-20 text-xl font-semibold tracking-tight; - } - .paragraph { - @apply leading-7 [&:not(:first-child)]:mt-6; - } - blockquote { - @apply mt-6 border-l-2 pl-6 italic; - } - ul { - @apply my-6 ml-6 list-disc [&>li]:mt-2; - } - .lead { - @apply text-xl text-muted-foreground; - } - .large { - @apply text-lg font-semibold; - } - .muted { - @apply text-sm text-muted-foreground; - } - small { - @apply text-sm font-medium leading-none; - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index deebe88..ebd980f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,20 @@ -import { Metadata } from "next"; +import { type Metadata } from "next"; import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google"; -import "plyr-react/plyr.css"; +import { headers } from "next/headers"; +import { BASE_URL } from "~/constant"; +import { type ActionResponseSchema } from "~/types"; -import { Footer, Navbar, Password, ThemeProvider } from "~/components/Layout"; -import ToTop from "~/components/Layout/ToTop"; +import { Footer, Navbar, Password, Provider, ToTop } from "~/components/layout"; -import { cn } from "~/utils/cn"; -import { formatFooter } from "~/utils/footerFormatter"; +import { cn, formatFooterContent } from "~/lib/utils"; + +import { CheckIndexPassword } from "~/actions/password"; +import "~/styles/code-highlight.css"; +import "~/styles/globals.css"; +import "~/styles/markdown.css"; -import { CheckSitePassword } from "actions"; import config from "config"; -import "./globals.css"; -import "./markdown.css"; - const sourceSans3 = Source_Sans_3({ weight: ["300", "400", "600", "700"], style: ["normal", "italic"], @@ -37,10 +38,10 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - metadataBase: new URL(config.basePath), + metadataBase: new URL(BASE_URL.includes("http") ? BASE_URL : `https://${BASE_URL}`), title: { default: config.siteConfig.siteName, - template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) || "%s", + template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) ?? "%s", }, description: config.siteConfig.siteDescription, authors: config.siteConfig.siteAuthor @@ -54,13 +55,13 @@ export const metadata: Metadata = { url: config.siteConfig.favIcon, }, ], - keywords: ["gdrive", "index", "nextjs", "reactjs"], + keywords: ["gdrive", "index", "nextjs", "reactjs", "google drive"], openGraph: { type: "website", siteName: config.siteConfig.siteName, images: [ { - url: "/og.png", + url: "/og.webp", width: 1200, height: 630, }, @@ -74,52 +75,68 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - let unlocked: Awaited> = { + const head = await headers(); + const pathname = head.get("X-Pathname") ?? "/"; + let unlocked: ActionResponseSchema = { success: true, - message: "", + message: "Index is public", + data: undefined, }; if (config.siteConfig.privateIndex) { - unlocked = await CheckSitePassword(); + unlocked = await CheckIndexPassword(); } return ( - + -
{config.siteConfig.privateIndex && !unlocked.success ? ( ) : ( <>{children} )}
- {config.siteConfig.footer &&