diff --git a/.env.example b/.env.example index d5646e0..e47aa02 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,5 @@ GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file ENCRYPTION_KEY= 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) NEXT_PUBLIC_DOMAIN= diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..8d871be --- /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..e097cfb 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..c2b9a6a 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..b8dbbb3 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,6 @@ +/* eslint-disable */ + +/** @type {import("next").NextConfig} */ const nextConfig = { reactStrictMode: true, webpack: (config) => { @@ -5,19 +8,6 @@ const nextConfig = { 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..19f8b84 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,69 @@ { "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", + "cypress": "cypress open" }, "dependencies": { "@cyntler/react-doc-viewer": "^1.13.0", - "@hookform/resolvers": "^3.3.4", + "@hookform/resolvers": "^3.9.1", + "@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-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.0", "@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-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.0", "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-tabs": "^1.0.4", + "@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", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.0", + "embla-carousel-react": "^8.5.1", "fflate": "^0.8.2", + "framer-motion": "^11.13.3", "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", + "lucide-react": "^0.468.0", + "next": "^15.1.2", + "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-dom": "^18.3.1", "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-hook-form": "^7.54.0", "react-markdown": "^8.0.7", "react-player": "^2.13.0", - "react-popper": "^2.3.0", "react-resizable-panels": "^2.0.16", - "react-toastify": "^9.1.3", "rehype-katex": "^6.0.3", "rehype-prism-plus": "^1.6.3", "rehype-raw": "6.1.1", @@ -76,33 +72,35 @@ "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", + "cypress": "^13.16.1", "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/files.ts b/src/actions/files.ts new file mode 100644 index 0000000..49d75c0 --- /dev/null +++ b/src/actions/files.ts @@ -0,0 +1,351 @@ +"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, + }; +} + +export async function CreateFileToken(id: string): Promise> { + return { + success: true, + message: "Token created", + data: "", + }; +} diff --git a/src/actions/password.ts b/src/actions/password.ts new file mode 100644 index 0000000..f2636a0 --- /dev/null +++ b/src/actions/password.ts @@ -0,0 +1,250 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { COOKIES_NAME, COOKIES_OPTIONS, USE_CACHE } from "~/constant"; +import { type ActionResponseSchema } from "~/types"; + +import { PathValidationCache } from "~/lib/cache"; +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 = USE_CACHE ? PathValidationCache.get(toUrlPath(paths)) ?? paths : 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..637bf4d --- /dev/null +++ b/src/actions/paths.ts @@ -0,0 +1,182 @@ +"use server"; + +import { USE_CACHE } from "~/constant"; +import { type ActionResponseSchema } from "~/types"; + +import { PathCache, PathValidationCache } from "~/lib/cache"; +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; + + if (PathCache.has(parentIdTemp) && USE_CACHE) { + const cachedPath = PathCache.get(parentIdTemp)!; + if (cachedPath) { + paths.unshift(cachedPath.name); + parentIdTemp = cachedPath.id; + continue; + } + } + + const { data } = await gdrive.files.get({ + fileId: parentIdTemp, + fields: "id,name,parents", + }); + if (!data.name) break; + + paths.unshift(data.name); + if (USE_CACHE) + PathCache.set(parentIdTemp, { + id: data.parents?.[0] ?? "", + name: 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; + + if (PathValidationCache.has(paths.join("/")) && USE_CACHE) { + const cachedPath = PathValidationCache.get(paths.join("/"))!; + if (cachedPath) return { success: true, message: "Paths validated from cache", data: cachedPath }; + } + + 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 ?? "", + }); + } + + if (USE_CACHE) PathValidationCache.set(paths.join("/"), response); + + 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/app/[...rest]/deploy/index.tsx b/src/app/[...rest]/deploy/index.tsx index 71bbdbf..833daeb 100644 --- a/src/app/[...rest]/deploy/index.tsx +++ b/src/app/[...rest]/deploy/index.tsx @@ -4,15 +4,15 @@ 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 { Icon, Markdown } from "~/components/global"; 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"; +import { cn } from "~/lib/utils"; const Sections = ["start", "new-user", "shared-drive", "migrating", "config", "theme"] as const; type Section = (typeof Sections)[number]; @@ -45,9 +45,9 @@ export default function DeployGuidePage() { ); return ( -
+
-
+
-
+
; }; +const isDeployGuide = (paths: string[]) => paths.length === 1 && paths[0] === "deploy" && config.showDeployGuide; -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; + if (isDeployGuide(rest)) return { title: "Deploy Guide", description: "Read on how to deploy this project" }; - 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, }, @@ -58,98 +59,185 @@ export async function generateMetadata({ params: { rest } }: Props, parent: Reso }; } -export default async function RestPage({ params: { rest } }: Props) { - if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return ; +export default async function RestPage({ params }: Props) { + const { rest } = await params; + if (isDeployGuide(rest)) 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 ; + } + + return ( + + + + ); + + // const paths = await CheckPaths(rest); + // if (!paths.success) notFound(); + // const unlocked = await CheckPassword(paths.data); + + // if (!unlocked.success) { + // if (!unlocked.path) + // throw new Error(`No path returned from password checking${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 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}`, + // }))} + // /> + + //
+ // {!("files" in data) ? ( + // + // ) : ( + // <> + // + // + //
+ // Browse files + // + //
+ // + //
+ // + // + // + //
+ // + // )} + //
+ + // {readme && ( + // + // )} + //
+ // ); } diff --git a/src/app/api/internal/encrypt/route.ts b/src/app/api/internal/encrypt/route.ts index 3e65fe2..31f026a 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 { encryptData } from "~/utils/encryptionHelper"; +import { encryptionService } from "~/lib/utils.server"; export const dynamic = "force-dynamic"; @@ -11,13 +11,13 @@ 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); + const encrypted = await encryptionService.encrypt(query, key ?? undefined); return NextResponse.json( { message: key ? "Encrypted with provided key" : "Encrypted with environment key", value: encrypted, - key, + key: key ?? process.env.ENCRYPTION_KEY, }, { status: 200 }, ); diff --git a/src/app/api/raw/[...rest]/route.ts b/src/app/api/raw/[...rest]/route.ts index 2481ddb..eeec52b 100644 --- a/src/app/api/raw/[...rest]/route.ts +++ b/src/app/api/raw/[...rest]/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { decryptData } from "~/utils/encryptionHelper"; diff --git a/src/app/api/raw/route.ts b/src/app/api/raw/route.ts new file mode 100644 index 0000000..35bda28 --- /dev/null +++ b/src/app/api/raw/route.ts @@ -0,0 +1,67 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { encryptionService } from "~/lib/utils.server"; + +import { GetFile } from "~/actions/files"; +import { ValidatePaths } from "~/actions/paths"; + +// TODO: Barebone implementation, add more protection like token, etc +export async function GET(req: NextRequest) { + const sp = new URL(req.nextUrl).searchParams; + const url = sp.get("url"); + try { + if (!url) + throw new Error("[400] Invalid raw api usage", { + cause: "No URL provided", + }); + const paths = url.split("/").filter(Boolean); + const validatePaths = await ValidatePaths(paths); + if (!validatePaths.success) + throw new Error(`[404] ${validatePaths.message}`, { + cause: validatePaths.error, + }); + + const currentFile = validatePaths.data.pop(); + if (!currentFile) + throw new Error("[404] File not found", { + cause: "Failed to get current file", + }); + if (["image", "video", "audio"].every((type) => !currentFile.mimeType.includes(type))) + throw new Error("[400] Invalid file type", { + cause: "Raw link only available for video, image, and audio files", + }); + const file = await GetFile(currentFile.id); + if (!file.success) + throw new Error(`[500] ${file.message}`, { + cause: file.error, + }); + if (!file.data?.encryptedWebContentLink) + throw new Error("[500] No download link found", { + cause: "No download link found", + }); + + const decryptedLink = await encryptionService.decrypt(file.data.encryptedWebContentLink); + + return new NextResponse(null, { + status: 302, + headers: { + Location: decryptedLink, + }, + }); + } 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/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 index 46c85e6..32fe381 100644 --- a/src/app/api/stream/[encryptedId]/route.ts +++ b/src/app/api/stream/[encryptedId]/route.ts @@ -1,8 +1,8 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; +import { IS_DEV } from "~/constant"; 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"; @@ -25,7 +25,7 @@ export async function GET( 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)) { + if (!IS_DEV && !request.headers.get("Referer")?.includes(config.basePath)) { throw new Error("Invalid request"); } @@ -92,7 +92,7 @@ If you've already entered the password, please make sure your browser is not blo let rangeStart = 0; let rangeEnd = Math.min(chunkSize, fileSize - 1); const rangeRegex = /bytes=(\d+)-(\d+)?/; - const rangeSize = ranges.match(rangeRegex); + const rangeSize = rangeRegex.exec(ranges); if (rangeSize) { rangeStart = parseInt(rangeSize[1], 10); diff --git a/src/app/api/thumb/[encryptedId]/route.ts b/src/app/api/thumb/[encryptedId]/route.ts index 0255c3d..663dc0e 100644 --- a/src/app/api/thumb/[encryptedId]/route.ts +++ b/src/app/api/thumb/[encryptedId]/route.ts @@ -1,24 +1,25 @@ -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"; 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"; // 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 +28,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..e72e579 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,38 +1,45 @@ "use client"; -import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import { cn } from "~/lib/utils"; -import { Icon } from "~/components/Global"; +import { Icon } from "~/components/global"; import { Button } from "~/components/ui/button"; -import { cn } from "~/utils/cn"; +import useRouter from "~/hooks/usePRouter"; -export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset?: () => void }) { const router = useRouter(); useEffect(() => { console.error(error); }, [error]); return ( -
+
Something went wrong More details can be found in the console
- + {error.message} -
+
diff --git a/src/app/globals.css b/src/app/globals.css 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..88ba934 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,19 @@ -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 { 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/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 +37,10 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - metadataBase: new URL(config.basePath), + metadataBase: new URL(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 +54,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,29 +74,42 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - let unlocked: Awaited> = { + 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 &&