feat: Configurator page finished

This commit is contained in:
mbaharip
2025-01-24 07:59:11 +07:00
parent 0c722c176b
commit f7e56c5023
8 changed files with 1012 additions and 191 deletions
+257 -1
View File
@@ -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<ActionResponseSchema<string>> {
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<ActionResponseSchema<z.infer<typeof Schema_App_Configuration_Env>>> {
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<ActionResponseSchema<Omit<z.infer<typeof Schema_App_Configuration>, "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<typeof Schema_App_Configuration>,
): Promise<ActionResponseSchema<{ configuration: string; env: string; zip: Blob }>> {
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" }),
},
};
}
+3 -3
View File
@@ -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<string>("");
@@ -181,7 +181,7 @@ export default function ApiForm({ form, onResetField }: FormProps) {
<FormField
control={form.control}
name='api.sharedDrive'
render={({ field, fieldState, formState }) => (
render={({ field, fieldState }) => (
<FormItem>
<div className='inline-flex w-full items-center justify-between gap-4 tablet:justify-start'>
<FormLabel
@@ -341,7 +341,7 @@ export default function ApiForm({ form, onResetField }: FormProps) {
<FormField
control={form.control}
name='api.hiddenFiles'
render={({ field, fieldState }) => {
render={({ fieldState }) => {
const watch = form.watch("api.hiddenFiles");
return (
+11 -8
View File
@@ -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}
/>
</FormControl>
<FormDescription>Maximum number of breadcrumbs item before it's truncated.</FormDescription>
<FormDescription>Maximum number of breadcrumbs item before it&apos;s truncated.</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -366,6 +366,7 @@ export default function SiteForm({ form, onResetField }: FormProps) {
/>
<Button
type='button'
className='col-span-full w-full'
variant={"secondary"}
onClick={() => {
@@ -426,6 +427,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Navbar Items</Label>
<Button
type='button'
variant={"ghost"}
disabled={!form.getFieldState("site.navbarItems").isDirty}
onClick={() => {
@@ -493,7 +495,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
)}
/>
</div>
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row '>
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
<FormField
control={form.control}
name={`site.navbarItems.${index}.href`}
@@ -518,7 +520,6 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
name={`site.navbarItems.${index}.external`}
render={({ field }) => (
<FormItem disableBorder>
<FormLabel>External Link</FormLabel>
<FormControl>
<Button
type='button'
@@ -534,7 +535,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
field.value ? "opacity-100" : "opacity-30",
)}
>
Open in new tab
External Link
</Button>
</FormControl>
<FormMessage />
@@ -558,8 +559,8 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
)}
<Button
className='w-full'
type='button'
className='w-full'
onClick={() => append({ icon: "Link", name: "New Item", href: "/new-item", external: false })}
>
<Icon name='Plus' />
@@ -583,6 +584,7 @@ function SupportsField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Supports / Donations</Label>
<Button
type='button'
variant={"ghost"}
disabled={!form.getFieldState("site.supports").isDirty}
onClick={() => {
@@ -746,6 +748,7 @@ function FooterField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Footer Items</Label>
<Button
type='button'
variant={"ghost"}
disabled={!form.getFieldState("site.footer").isDirty}
onClick={() => {
@@ -1,4 +1,4 @@
import { ChangeEvent, useRef, useState } from "react";
import { type ChangeEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "~/components/ui/button";
@@ -7,7 +7,7 @@ import { Input } from "~/components/ui/input";
import { GenerateServiceAccountB64 } from "~/actions/configuration";
import { FormProps, FormSection } from "./ConfiguratorPage";
import { type FormProps, FormSection } from "./ConfiguratorPage";
export default function EnvironmentForm({ onResetField, form }: FormProps) {
const serviceInputRef = useRef<HTMLInputElement>(null);
@@ -86,6 +86,7 @@ export default function EnvironmentForm({ onResetField, form }: FormProps) {
loading={serviceInputLoading}
size={"sm"}
onClick={() => serviceInputRef.current?.click()}
type='button'
>
Load JSON
</LoadingButton>
+209 -141
View File
@@ -3,105 +3,29 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { type PropsWithChildren, useState } from "react";
import { FieldPath, type UseFormReturn, useForm } from "react-hook-form";
import { type FieldPath, type UseFormReturn, useForm } from "react-hook-form";
import { toast } from "sonner";
import { type z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
ResponsiveDropdownMenu,
ResponsiveDropdownMenuContent,
ResponsiveDropdownMenuItem,
ResponsiveDropdownMenuTrigger,
} from "~/components/ui/dropdown-menu.responsive";
import { Button, LoadingButton } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import { Form } from "~/components/ui/form";
import Icon from "~/components/ui/icon";
import { Separator } from "~/components/ui/separator";
import { useResponsive } from "~/context/responsiveContext";
import { type PickFileResponse, initialConfiguration, pickFile, versionExpectMap } from "~/lib/configurationHelper";
import { type ConfigurationCategory, Schema_App_Configuration } from "~/types/schema";
import { GenerateConfiguration, ProcessConfiguration, ProcessEnvironmentConfig } from "~/actions/configuration";
import ApiForm from "./ConfigurationPage.Api";
import SiteForm from "./ConfigurationPage.Site";
import EnvironmentForm from "./ConfiguratorPage.Environment";
const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
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 default function ConfiguratorPage() {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isLoadingEnv, setIsLoadingEnv] = useState<boolean>(false);
const [isLoadingConfig, setIsLoadingConfig] = useState<boolean>(false);
const form = useForm<z.infer<typeof Schema_App_Configuration>>({
resolver: zodResolver(Schema_App_Configuration),
defaultValues: initialConfiguration,
@@ -113,11 +37,146 @@ export default function ConfiguratorPage() {
} else {
form.resetField(category);
}
toast.success("Form reverted to initial state");
}
function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) {
toast.info("Form submitted", {
description: <pre className='w-full overflow-auto'>{JSON.stringify(values, null, 2)}</pre>,
async function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) {
const id = `download-${Date.now()}`;
toast.loading("Generating configuration...", {
id,
duration: 0,
});
const data = await GenerateConfiguration(values);
if (!data.success) {
toast.error(data.message, {
id,
description: data.error,
});
return;
}
const url = URL.createObjectURL(data.data.zip);
const a = document.createElement("a");
a.href = url;
a.download = `${Date.now()}-gIndex.config.zip`;
a.click();
URL.revokeObjectURL(url);
a.remove();
toast.success("Configuration generated", {
id,
});
}
async function onLoadEnv(response: PickFileResponse) {
const id = `env-latest-${Date.now()}`;
toast.loading("Waiting for file...", {
id,
duration: 0,
});
if (!response.success) {
toast.error(response.message, {
id,
description: response.details.length ? (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{response.details.join("\n")}
</pre>
) : undefined,
});
setIsLoadingEnv(false);
return;
}
toast.loading("Processing environment file...", {
id,
duration: 0,
});
const data = await ProcessEnvironmentConfig(response.data);
if (!data.success) {
toast.error(data.message, {
id,
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
});
setIsLoadingEnv(false);
return;
}
form.setValue("environment", data.data);
toast.success(data.message, {
id,
});
setIsLoadingEnv(false);
}
async function onLoadConfig(response: PickFileResponse) {
const id = `config-${Date.now()}`;
toast.loading("Waiting for file...", {
id,
duration: 0,
});
if (!response.success) {
toast.error(response.message, {
id,
description: response.details.length ? (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{response.details.join("\n")}
</pre>
) : undefined,
});
setIsLoadingConfig(false);
return;
}
const loadedVersion = /version:\s*["']?(\d+\.\d+\.\d+)["']?/.exec(response.data)?.[1];
if (!loadedVersion) {
toast.error("Version not found in configuration file", {
id,
});
setIsLoadingConfig(false);
return;
}
const versionGroup = Object.entries(versionExpectMap).find(([_, v]) => v.includes(loadedVersion))?.[0];
if (!versionGroup) {
toast.error("Version not recognized", {
id,
description: (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{`Loaded version: ${loadedVersion}, not matching any known version`}
</pre>
),
});
setIsLoadingConfig(false);
return;
}
toast.loading(`Version ${loadedVersion} detected, processing...`, {
id,
duration: 0,
});
const data = await ProcessConfiguration(response.data, versionGroup as "v1" | "v2" | "latest");
if (!data.success) {
toast.error(data.message, {
id,
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
});
setIsLoadingConfig(false);
return;
}
form.setValue("api", data.data.api);
form.setValue("site", data.data.site);
form.setValue("site.navbarItems", data.data.site.navbarItems);
form.setValue("site.supports", data.data.site.supports);
form.setValue("site.footer", data.data.site.footer);
toast.success(data.message, {
id,
});
setIsLoadingConfig(false);
}
return (
@@ -130,7 +189,7 @@ export default function ConfiguratorPage() {
href={"https://themes.fkaya.dev/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
themes.fkaya.dev
</Link>
@@ -139,7 +198,7 @@ export default function ConfiguratorPage() {
href={"https://themeshadcn.com/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
themeshadcn.com
</Link>{" "}
@@ -148,7 +207,7 @@ export default function ConfiguratorPage() {
href={"https://ui.jln.dev/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
ui.jln.dev
</Link>{" "}
@@ -157,47 +216,44 @@ export default function ConfiguratorPage() {
</Alert>
<Card>
<CardHeader>
<CardTitle>Configurator</CardTitle>
<CardDescription>Generate configurator for your index.</CardDescription>
</CardHeader>
<CardContent>
<div className='flex w-full flex-col items-center gap-2 pb-4 tablet:flex-row-reverse'>
<ResponsiveDropdownMenu>
<ResponsiveDropdownMenuTrigger asChild>
<Button className='w-full tablet:w-fit'>
Load Config
<Icon name='ChevronsUpDown' />
</Button>
</ResponsiveDropdownMenuTrigger>
<ResponsiveDropdownMenuContent
header={{
title: "Load Config",
description: "Load configuration from existing file",
}}
>
<ResponsiveDropdownMenuItem closeOnSelect>v2.4 / latest</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v2.3 / below</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem>
</ResponsiveDropdownMenuContent>
</ResponsiveDropdownMenu>
<ResponsiveDropdownMenu>
<ResponsiveDropdownMenuTrigger asChild>
<Button className='w-full tablet:w-fit'>
Load Env
<Icon name='ChevronsUpDown' />
</Button>
</ResponsiveDropdownMenuTrigger>
<ResponsiveDropdownMenuContent
header={{
title: "Load Env",
description: "Load environment variables from existing file",
}}
>
<ResponsiveDropdownMenuItem closeOnSelect>v2.x / latest</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem>
</ResponsiveDropdownMenuContent>
</ResponsiveDropdownMenu>
<CardHeader className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-center tablet:justify-between'>
<div className='flex grow flex-col space-y-1.5'>
<CardTitle>Configurator</CardTitle>
<CardDescription>Generate configurator for your index.</CardDescription>
</div>
<div className='flex flex-col items-center gap-2 tablet:flex-row-reverse'>
<LoadingButton
loading={isLoadingConfig}
className='w-full tablet:w-fit'
onClick={() => {
setIsLoadingConfig(true);
pickFile({
accept: ".ts",
async onLoad(response) {
await onLoadConfig(response);
},
});
}}
>
Load Config
</LoadingButton>
<LoadingButton
loading={isLoadingEnv}
className='w-full tablet:w-fit'
onClick={() => {
setIsLoadingEnv(true);
pickFile({
accept: ".env",
async onLoad(response) {
await onLoadEnv(response);
},
});
}}
>
Load Env
</LoadingButton>
<Button
className='w-full tablet:w-fit'
variant={"destructive"}
@@ -206,11 +262,14 @@ export default function ConfiguratorPage() {
Reset All
</Button>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
className='grid grid-cols-1 gap-8 tablet:px-2'
>
</CardHeader>
<Separator className='mb-6' />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
className='grid grid-cols-1'
>
<CardContent className='grid grid-cols-1 gap-8'>
<EnvironmentForm
form={form}
onResetField={(field) => form.resetField(field)}
@@ -229,9 +288,20 @@ export default function ConfiguratorPage() {
form={form}
onResetField={(field) => form.resetField(field)}
/>
</form>
</Form>
</CardContent>
</CardContent>
<CardFooter>
<LoadingButton
size={"lg"}
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid || !form.formState.isDirty}
type='submit'
className='w-full'
>
Generate Configuration
</LoadingButton>
</CardFooter>
</form>
</Form>
</Card>
</>
);
@@ -262,8 +332,6 @@ type FormSectionProps = {
description: string;
};
export function FormSection({ title, description, children }: PropsWithChildren<FormSectionProps>) {
const { isDesktop } = useResponsive();
return (
<div
id={title
+441 -15
View File
@@ -1,6 +1,171 @@
import { z } from "zod";
import { type z } from "zod";
const configurationTemplate = `import { type z } from "zod";
import {
Schema_App_Configuration,
Schema_App_Configuration_Env,
Schema_Config,
Schema_v1_Config,
Schema_v2_3_Config,
} from "~/types/schema";
import config from "~/config/gIndex.config";
export const versionExpectMap: Record<"v1" | "v2" | "latest", string[]> = {
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<void>;
onCancel?: () => void | Promise<void>;
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<typeof Schema_App_Configuration> = {
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<typeof Schema_Config> = {
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<T extends z.ZodObject<any>> =
| z.infer<T>
| {
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<typeof formSchema> = {
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<typeof formSchema> = {
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<typeof formSchema> = {
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<typeof latestEnvironmentSchema> {
const lines = configuration.split("\n");
const result: Record<string, string> = {};
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;
}
+19 -10
View File
@@ -64,11 +64,17 @@ export const base64Encode = (text: string, type: B64Type = "url") => {
if (type === "standard") return encodeBase64(data);
return encodeBase64url(data);
};
export const base64Decode = <T = unknown>(encoded: string, type: B64Type = "url"): T => {
let decoded: Uint8Array<ArrayBufferLike>;
if (type === "standard") decoded = decodeBase64(encoded);
else decoded = decodeBase64url(encoded);
return new TextDecoder().decode(decoded) as T;
export const base64Decode = <T = unknown>(encoded: string, type: B64Type = "url"): T | null => {
try {
let decoded: Uint8Array<ArrayBufferLike>;
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<string>(process.env.GD_SERVICE_B64!)) as ServiceAccount;
const decodedB64 = base64Decode<string>(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");
+69 -11
View File
@@ -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(),
}),
}),