From f7e56c5023558a90f6a1369a1dda8035827bfa03 Mon Sep 17 00:00:00 2001 From: mbaharip <62494292+mbahArip@users.noreply.github.com> Date: Fri, 24 Jan 2025 07:59:11 +0700 Subject: [PATCH] feat: Configurator page finished --- src/actions/configuration.ts | 258 +++++++++- src/app/[...rest]/ConfigurationPage.Api.tsx | 6 +- src/app/[...rest]/ConfigurationPage.Site.tsx | 19 +- .../ConfiguratorPage.Environment.tsx | 5 +- src/app/[...rest]/ConfiguratorPage.tsx | 350 ++++++++------ src/lib/configurationHelper.ts | 456 +++++++++++++++++- src/lib/utils.server.ts | 29 +- src/types/schema.ts | 80 ++- 8 files changed, 1012 insertions(+), 191 deletions(-) diff --git a/src/actions/configuration.ts b/src/actions/configuration.ts index 0fca0b2..7dd7b97 100644 --- a/src/actions/configuration.ts +++ b/src/actions/configuration.ts @@ -1,14 +1,32 @@ "use server"; +import { type AsyncZippable, strToU8, zipSync } from "fflate"; +import { type z } from "zod"; import { type ActionResponseSchema } from "~/types"; -import { base64Decode, base64Encode } from "~/lib/utils.server"; +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, @@ -23,3 +41,241 @@ export async function GenerateServiceAccountB64(serviceAccount: string): Promise 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/app/[...rest]/ConfigurationPage.Api.tsx b/src/app/[...rest]/ConfigurationPage.Api.tsx index 4c3e7b9..7fc253e 100644 --- a/src/app/[...rest]/ConfigurationPage.Api.tsx +++ b/src/app/[...rest]/ConfigurationPage.Api.tsx @@ -11,7 +11,7 @@ import { Switch } from "~/components/ui/switch"; import { cn } from "~/lib/utils"; -import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage"; +import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage"; export default function ApiForm({ form, onResetField }: FormProps) { const [inputHiddenFile, setInputHiddenFile] = useState(""); @@ -181,7 +181,7 @@ export default function ApiForm({ form, onResetField }: FormProps) { ( + render={({ field, fieldState }) => (
{ + render={({ fieldState }) => { const watch = form.watch("api.hiddenFiles"); return ( diff --git a/src/app/[...rest]/ConfigurationPage.Site.tsx b/src/app/[...rest]/ConfigurationPage.Site.tsx index 4264458..3605455 100644 --- a/src/app/[...rest]/ConfigurationPage.Site.tsx +++ b/src/app/[...rest]/ConfigurationPage.Site.tsx @@ -4,7 +4,7 @@ import { useFieldArray } from "react-hook-form"; import ReactMarkdown from "react-markdown"; import remarkBreaks from "remark-breaks"; import { toast } from "sonner"; -import { z } from "zod"; +import { type z } from "zod"; import { Button } from "~/components/ui/button"; import { VirtualizedCombobox } from "~/components/ui/combobox.virtualized"; @@ -31,9 +31,9 @@ import { Textarea } from "~/components/ui/textarea"; import { useResponsive } from "~/context/responsiveContext"; import { cn, formatFooterContent } from "~/lib/utils"; -import { Schema_App_Configuration } from "~/types/schema"; +import { type Schema_App_Configuration } from "~/types/schema"; -import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage"; +import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage"; export default function SiteForm({ form, onResetField }: FormProps) { return ( @@ -297,7 +297,7 @@ export default function SiteForm({ form, onResetField }: FormProps) { {...field} /> - Maximum number of breadcrumbs item before it's truncated. + Maximum number of breadcrumbs item before it's truncated. )} @@ -366,6 +366,7 @@ export default function SiteForm({ form, onResetField }: FormProps) { />
-
+
( - External Link @@ -558,8 +559,8 @@ function NavbarItemsField({ form, onResetField }: FormProps) { )} - - - v2.4 / latest - v2.3 / below - v1.x / legacy - - - - - - - - v2.x / latest - v1.x / legacy - - + +
+ Configurator + Generate configurator for your index. +
+
+ { + setIsLoadingConfig(true); + + pickFile({ + accept: ".ts", + async onLoad(response) { + await onLoadConfig(response); + }, + }); + }} + > + Load Config + + { + setIsLoadingEnv(true); + + pickFile({ + accept: ".env", + async onLoad(response) { + await onLoadEnv(response); + }, + }); + }} + > + Load Env +
-
- + + + + + form.resetField(field)} @@ -229,9 +288,20 @@ export default function ConfiguratorPage() { form={form} onResetField={(field) => form.resetField(field)} /> - - - + + + + Generate Configuration + + + + ); @@ -262,8 +332,6 @@ type FormSectionProps = { description: string; }; export function FormSection({ title, description, children }: PropsWithChildren) { - const { isDesktop } = useResponsive(); - return (
= { + v1: ["1.0.0", "1.0.1", "1.0.2", "1.0.3"], + v2: ["2.0.0", "2.0.1", "2.0.2", "2.0.3"], + latest: ["2.0.4"], +}; +export type PickFileResponse = + | { + success: true; + data: string; + } + | { + success: false; + message: string; + details: string[]; + }; +type PickFileProps = { + accept: string; + onLoad: (response: PickFileResponse) => void | Promise; + onCancel?: () => void | Promise; + fileName?: string; +}; +export function pickFile({ accept, fileName, onLoad }: PickFileProps): void { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = accept; + fileInput.oncancel = async () => { + await onLoad({ + success: false, + message: "File picker canceled", + details: [], + }); + }; + fileInput.onchange = async (fileEvent) => { + const file = (fileEvent.target as HTMLInputElement).files?.[0]; + if (!file) { + await onLoad({ + success: false, + message: "No file selected", + details: [], + }); + return; + } + + if (fileName) { + const { name } = file; + if (name.toLowerCase() !== fileName.toLowerCase()) { + await onLoad({ + success: false, + message: `Invalid file name`, + details: [`Expected: ${fileName}`, `Received: ${name}`], + }); + return; + } + } + + const reader = new FileReader(); + reader.onload = async (readerEvent) => { + const result = readerEvent.target?.result as string; + if (!result) { + await onLoad({ + success: false, + message: "Failed to read file", + details: [], + }); + return; + } + fileInput.value = ""; + + await onLoad({ + success: true, + data: result, + }); + }; + reader.onloadend = () => { + fileInput.remove(); + }; + reader.readAsText(file); + }; + fileInput.click(); + fileInput.remove(); +} + +export const initialConfiguration: z.input = { + version: config.version, + environment: { + ENCRYPTION_KEY: "", + SITE_PASSWORD: "", + GD_SERVICE_B64: "", + NEXT_PUBLIC_DOMAIN: "", + }, + + api: { + cache: { + public: true, + maxAge: 60, + sMaxAge: 60, + staleWhileRevalidate: true, + }, + rootFolder: "", + isTeamDrive: false, + sharedDrive: "", + defaultQuery: ["trashed = false", "(not mimeType contains 'google-apps' or mimeType contains 'folder')"], + defaultField: + "id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed", + defaultOrder: "folder, name asc, modifiedTime desc", + itemsPerPage: 50, + searchResult: 5, + proxyThumbnail: true, + streamMaxSize: 100 * 1024 * 1024, + specialFile: { + password: ".password", + readme: ".readme.md", + banner: ".banner", + }, + hiddenFiles: [".password", ".readme.md", ".banner", ".banner.jpg", ".banner.png", ".banner.webp"], + allowDownloadProtectedFile: false, + temporaryTokenDuration: 6, + maxFileSize: 4 * 1024 * 1024, + }, + + site: { + guideButton: false, + + siteName: "next-gdrive-index", + siteNameTemplate: "%s - %t", + siteDescription: "A simple file browser for Google Drive", + siteIcon: "/logo.svg", + siteAuthor: "mbaharip", + favIcon: "/favicon.png", + + robots: "noindex, nofollow", + twitterHandle: "@mbaharip_", + showFileExtension: true, + footer: [ + { value: "{{ poweredBy }}" }, + { value: "Made with ❤️ by [**{{ author }}**](https://github.com/mbaharip)" }, + ], + experimental_pageLoadTime: false, + privateIndex: false, + breadcrumbMax: 3, + toaster: { + position: "bottom-right", + duration: 3000, + }, + navbarItems: [], + supports: [], + previewSettings: { + manga: { + maxSize: 15 * 1024 * 1024, + maxItem: 10, + }, + }, + }, +}; + +export const configurationTemplate = `import { type z } from "zod"; import { BASE_URL } from "~/constant"; import { type Schema_Config } from "~/types/schema"; @@ -293,7 +458,7 @@ const config: z.input = { export default config; `; -const environmentTemplate = `# Base64 Encoded Service Account JSON +export const environmentTemplate = `# Base64 Encoded Service Account JSON GD_SERVICE_B64={{ serviceAccount }} # Secret Key for Encryption ENCRYPTION_KEY={{ key }} @@ -304,18 +469,279 @@ SITE_PASSWORD={{ password }} # Needed if you're not using Vercel NEXT_PUBLIC_DOMAIN={{ domain }}`; -const version1Schema = z.object({}); -const version2Schema = z.object({}); -const newVersion2Schema = z.object({}); -const environmentSchema = z.object({ - GD_SERVICE_B64: z.string(), - ENCRYPTION_KEY: z.string(), - SITE_PASSWORD: z.string().optional(), - NEXT_PUBLIC_DOMAIN: z.string().optional(), -}); +const version1Schema = Schema_v1_Config; +const version2Schema = Schema_v2_3_Config; +const newVersion2Schema = Schema_Config; +const formSchema = Schema_App_Configuration.omit({ environment: true }); +const latestEnvironmentSchema = Schema_App_Configuration_Env; -export function parseVersion1Config(configuration: string) {} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ConfigurationResponse> = + | z.infer + | { + message: string; + details: string[]; + }; -export function parseVersion2Config(configuration: string) {} +export function parseVersion1Config(configuration: string) { + const config = (configuration.split(/const config:\s.*?=\s/g)[1]?.split("export default config;")[0] ?? "") -export function parseEnvironmentConfig(configuration: string) {} + .replace(/\\/g, "") // Remove all escape backslashes + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove all multi-line comments + .replace(/,\s\/\/\s.*/g, ",") // Remove comments after values + .replace(/[^,]\/\/\s.*?,/g, "") // Remove single line comments + + .replace(/\r\n/g, "") + .replace(/\n/g, "") + .replace(/\t/g, "") // Remove line breaks and tabs + + .replace(/basePath:(.*?),/g, 'basePath: "placeholder-domain",') // Replace basePath variable with placeholder + .replace(/maxFileSize:(.*?),/g, (str) => { + if (!str) return "maxFileSize: 4194304,"; // Set maxFileSize to 4MB + const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? ""; + const numbers = value.split("*").map((v) => Number(v ?? "1")); + return `maxFileSize: ${numbers.reduce((a, b) => a * b, 1)}`; + }) + + .replace(/([a-zA-Z]*?):\s/g, '"$1": ') // Add double quotes to keys + .replace(/\s{2,4}|/g, "") // Replace all double+ spaces with single space + .replace(/,(?=[^,]*$)/, "") // Remove trailing comma + .replace(/(,\])/g, "]") // Remove trailing comma before closing bracket + .replace(/(,\})/g, "}") // Remove trailing comma before closing brace + .slice(0, -1); + + const json = JSON.parse(config) as object; + const parsedJson = version1Schema.safeParse(json); + if (!parsedJson.success) { + return { + message: "Failed to match the version 1 schema", + details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + const { data } = parsedJson; + + const migratedData: z.input = { + version: data.version, + api: { + ...initialConfiguration.api, + cache: { + public: data.cacheControl.includes("public"), + maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"), + }, + rootFolder: data.apiConfig.rootFolder, + isTeamDrive: false, + sharedDrive: "", + itemsPerPage: data.apiConfig.itemsPerPage, + searchResult: data.apiConfig.searchResult, + specialFile: { + password: data.apiConfig.specialFile.password, + readme: data.apiConfig.specialFile.readme, + banner: data.apiConfig.specialFile.banner, + }, + hiddenFiles: data.apiConfig.hiddenFiles, + allowDownloadProtectedFile: data.apiConfig.allowDownloadProtectedFile, + temporaryTokenDuration: data.apiConfig.temporaryTokenDuration, + maxFileSize: data.apiConfig.maxFileSize, + }, + site: { + ...initialConfiguration.site, + guideButton: false, + siteName: data.siteConfig.siteName, + siteDescription: data.siteConfig.siteDescription, + twitterHandle: data.siteConfig.twitterHandle ?? initialConfiguration.site.twitterHandle ?? "@__mbaharip__", + privateIndex: data.siteConfig.privateIndex, + navbarItems: data.siteConfig.navbarItems.map((item) => ({ + icon: "File", + name: item.name, + href: item.href, + external: item.external ?? false, + })), + }, + }; + + const parsedData = formSchema.safeParse(migratedData); + if (!parsedData.success) { + return { + message: "Failed to migrate the old configuration to the new schema", + details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + return parsedData.data; +} + +export function parseVersion2Config(configuration: string) { + const isLatest = configuration.includes('version: "2.0.4"'); + const config = (configuration.split(/const config:\s.*?=\s/g)[1]?.split("export default config;")[0] ?? "") + + .replace(/\\/g, "") // Remove all escape backslashes + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove all multi-line comments + .replace(/,\s\/\/\s.*/g, ",") // Remove comments after values + .replace(/[^,]\/\/\s.*?,/g, "") // Remove single line comments + + .replace(/\r\n/g, "") + .replace(/\n/g, "") + .replace(/\t/g, "") // Remove line breaks and tabs + + .replace(/basePath:(.*?),/g, 'basePath: "placeholder-domain",') // Replace basePath variable with placeholder + .replace(/streamMaxSize:(.*?),/g, (str) => { + if (!str) return "streamMaxSize: 104857600,"; // Set streamMaxSize to 100MB + const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? ""; + const numbers = value.split("*").map((v) => Number(v ?? "1")); + return `streamMaxSize: ${numbers.reduce((a, b) => a * b, 1)},`; + }) + .replace(/temporaryTokenDuration:(.*?),/g, (str) => { + if (!str) return "temporaryTokenDuration: 6,"; // Set temporaryTokenDuration to 6 hours + const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? ""; + if (value.includes("/")) { + const numbers = value.split("/").map((v) => Number(v ?? "1")); + return `temporaryTokenDuration: ${numbers.reduce((a, b) => a / b, 1)},`; + } else { + return `temporaryTokenDuration: ${value},`; + } + }) + .replace(/maxFileSize:(.*?),/g, (str) => { + if (!str) return "maxFileSize: 4194304,"; // Set maxFileSize to 4MB + const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? ""; + const numbers = value.split("*").map((v) => Number(v ?? "1")); + return `maxFileSize: ${numbers.reduce((a, b) => a * b, 1)},`; + }) + .replace(/maxSize:(.*?),/g, (str) => { + if (!str) return "maxSize: 15728640,"; // Set maxSize to 15MB + const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? ""; + const numbers = value.split("*").map((v) => Number(v ?? "1")); + return `maxSize: ${numbers.reduce((a, b) => a * b, 1)},`; + }) + + .replace(/([a-zA-Z_]*?):\s/g, '"$1": ') // Add double quotes to keys + .replace(/\s{2,4}|/g, "") // Replace all double+ spaces with single space + .replace(/,(?=[^,]*$)/, "") // Remove trailing comma + .replace(/(,\])/g, "]") // Remove trailing comma before closing bracket + .replace(/(,\})/g, "}") // Remove trailing comma before closing brace + .slice(0, -1); + + const json = JSON.parse(config) as object; + if (isLatest) { + const parsedJson = newVersion2Schema.safeParse(json); + if (!parsedJson.success) { + return { + message: `Failed to match the schema for ${isLatest ? "latest" : "version 2.3 / below"} configuration`, + details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + const { data } = parsedJson; + + const migratedData: z.input = { + version: data.version, + api: { + ...initialConfiguration.api, + ...data.apiConfig, + rootFolder: "", + isTeamDrive: false, + sharedDrive: "", + cache: { + public: data.cacheControl.includes("public"), + maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"), + }, + }, + site: { + ...initialConfiguration.site, + ...data.siteConfig, + guideButton: false, + }, + }; + + const parsedData = formSchema.safeParse(migratedData); + if (!parsedData.success) { + return { + message: "Failed to migrate the old configuration to the new schema", + details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + return parsedData.data; + } else { + const parsedJson = version2Schema.safeParse(json); + if (!parsedJson.success) { + return { + message: `Failed to match the schema for ${isLatest ? "latest" : "version 2.3 / below"} configuration`, + details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + const { data } = parsedJson; + + const migratedData: z.input = { + version: data.version, + api: { + ...initialConfiguration.api, + rootFolder: "", + isTeamDrive: false, + sharedDrive: "", + cache: { + public: data.cacheControl.includes("public"), + maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60), + staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"), + }, + }, + site: { + ...initialConfiguration.site, + ...data.siteConfig, + footer: (data.siteConfig.footer ?? []).map((item) => ({ value: item })), + guideButton: false, + }, + }; + + const parsedData = formSchema.safeParse(migratedData); + if (!parsedData.success) { + return { + message: "Failed to migrate the old configuration to the new schema", + details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + } + + return parsedData.data; + } +} + +export function parseEnvironment(configuration: string): ConfigurationResponse { + const lines = configuration.split("\n"); + const result: Record = {}; + const availableKeys = [ + "GD_SERVICE_B64", + "ENCRYPTION_KEY", + "SITE_PASSWORD", + "NEXT_PUBLIC_DOMAIN", + "NEXT_PUBLIC_VERCEL_URL", + "NEXT_PUBLIC_ENCRYPTION_KEY", + "NEXT_PUBLIC_SITE_PASSWORD", + ]; + for (const line of lines) { + if (!availableKeys.some((key) => line.startsWith(key))) { + continue; + } + + const [key, value] = line.split("="); + if (!key || !value) continue; + const formattedValue = value.replace(/"/g, "").trim(); + const formattedKey = ["NEXT_PUBLIC_ENCRYPTION_KEY", "NEXT_PUBLIC_SITE_PASSWORD"].includes(key) + ? key.replace("NEXT_PUBLIC_", "") + : key; + result[formattedKey] = formattedValue; + } + const validated = latestEnvironmentSchema.safeParse(result); + if (!validated.success) + return { + message: "Failed to match the latest environment schema", + details: validated.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`), + }; + + return validated.data; +} diff --git a/src/lib/utils.server.ts b/src/lib/utils.server.ts index 964f0a4..d71a997 100644 --- a/src/lib/utils.server.ts +++ b/src/lib/utils.server.ts @@ -64,11 +64,17 @@ export const base64Encode = (text: string, type: B64Type = "url") => { if (type === "standard") return encodeBase64(data); return encodeBase64url(data); }; -export const base64Decode = (encoded: string, type: B64Type = "url"): T => { - let decoded: Uint8Array; - if (type === "standard") decoded = decodeBase64(encoded); - else decoded = decodeBase64url(encoded); - return new TextDecoder().decode(decoded) as T; +export const base64Decode = (encoded: string, type: B64Type = "url"): T | null => { + try { + let decoded: Uint8Array; + if (type === "standard") decoded = decodeBase64(encoded); + else decoded = decodeBase64url(encoded); + return new TextDecoder().decode(decoded) as T; + } catch (error) { + const e = error as Error; + console.error(`[base64Decode] ${e.message}`); + return null; + } }; type ServiceAccount = { @@ -90,15 +96,18 @@ class GoogleDriveService { public gdriveNoCache: drive_v3.Drive; constructor() { - const decodedBase64 = JSON.parse(base64Decode(process.env.GD_SERVICE_B64!)) as ServiceAccount; + const decodedB64 = base64Decode(process.env.GD_SERVICE_B64!); + if (!decodedB64) throw new Error("Failed to decode GD_SERVICE_B64"); + const parsedAuth = JSON.parse(decodedB64) as ServiceAccount; + this.auth = new google.auth.GoogleAuth({ credentials: { type: "service_account", - private_key: decodedBase64.private_key, - client_email: decodedBase64.client_email, - client_id: decodedBase64.client_id, + private_key: parsedAuth.private_key, + client_email: parsedAuth.client_email, + client_id: parsedAuth.client_id, }, - projectId: decodedBase64.project_id, + projectId: parsedAuth.project_id, scopes: ["https://www.googleapis.com/auth/drive"], }); if (!this.auth) throw new Error("Failed to initialize Google Auth"); diff --git a/src/types/schema.ts b/src/types/schema.ts index fe5fb1f..f34ca4a 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -40,7 +40,7 @@ export const Schema_File = z.object({ .nullable(), }); -export const Schema_Old_Config = z.object({ +export const Schema_v1_Config = z.object({ version: z.literal("1.0.0"), basePath: z.string(), masterKey: z.string(), @@ -54,7 +54,7 @@ export const Schema_Old_Config = z.object({ defaultField: z.string(), defaultOrder: z.string(), itemsPerPage: z.number().positive(), - searchRsult: z.number().positive(), + searchResult: z.number().positive(), specialFile: z.object({ password: z.string(), @@ -116,10 +116,56 @@ export const Schema_Config_API = z }) .refine( (data) => { - if (data.isTeamDrive && !data.sharedDrive) return false; + if (data.isTeamDrive === true && !data.sharedDrive) return false; + return true; }, { message: "sharedDrive is required when isTeamDrive is true" }, ); + +export const Schema_v2_3_Config_Site = z.object({ + siteName: z.string(), + siteNameTemplate: z.string().optional().default("%s"), + siteDescription: z.string(), + siteIcon: z.string(), + siteAuthor: z.string().optional().default("mbaharip"), + favIcon: z.string(), + robots: z.string().optional().default("noindex, nofollow"), + twitterHandle: z.string().optional().default("@__mbaharip__"), + + showFileExtension: z.boolean().optional().default(false), + + footer: z.string().array().optional(), + + privateIndex: z.boolean().optional().default(false), + breadcrumbMax: z.number(), + + toaster: z + .object({ + position: z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]), + duration: z.number().positive(), + }) + .optional() + .default({ + position: "top-right", + duration: 5000, + }), + + navbarItems: z.array( + z.object({ + icon: z.enum(Object.keys(icons) as [keyof typeof icons]), + name: z.string(), + href: z.string(), + external: z.boolean().optional().default(false), + }), + ), + supports: z.array( + z.object({ + name: z.string(), + currency: z.string(), + href: z.string(), + }), + ), +}); export const Schema_Config_Site = z.object({ siteName: z.string(), siteNameTemplate: z.string().optional().default("%s"), @@ -181,13 +227,17 @@ export const Schema_Config_Site = z.object({ }), }), }); -export const Schema_App_Configuration_Env = z.object({ - GD_SERVICE_B64: z.string(), - ENCRYPTION_KEY: z.string(), - SITE_PASSWORD: z.string().optional(), - NEXT_PUBLIC_DOMAIN: z.string().optional(), -}); +export const Schema_v2_3_Config = z.object({ + version: z.string(), + basePath: z.string(), + cacheControl: z.string(), + showDeployGuide: z.boolean(), + + apiConfig: Schema_Config_API, + + siteConfig: Schema_v2_3_Config_Site, +}); export const Schema_Config = z.object({ version: z.string(), basePath: z.string(), @@ -199,6 +249,13 @@ export const Schema_Config = z.object({ siteConfig: Schema_Config_Site, }); +export const Schema_App_Configuration_Env = z.object({ + GD_SERVICE_B64: z.string(), + ENCRYPTION_KEY: z.string(), + SITE_PASSWORD: z.string().optional(), + NEXT_PUBLIC_DOMAIN: z.string().optional(), +}); + export const Schema_ServiceAccount = z.object({ type: z.literal("service_account"), project_id: z.string(), @@ -214,13 +271,14 @@ export const Schema_ServiceAccount = z.object({ }); export const Schema_App_Configuration = z.object({ + version: z.string(), environment: Schema_App_Configuration_Env, api: Schema_Config_API.and( z.object({ cache: z.object({ public: z.coerce.boolean(), - maxAge: z.coerce.number().positive(), - sMaxAge: z.coerce.number().positive(), + maxAge: z.coerce.number().min(0), + sMaxAge: z.coerce.number().min(0), staleWhileRevalidate: z.coerce.boolean(), }), }),