mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
feat: Configurator page finished
This commit is contained in:
@@ -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" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</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'>
|
||||
</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
|
||||
<Icon name='ChevronsUpDown' />
|
||||
</Button>
|
||||
</ResponsiveDropdownMenuTrigger>
|
||||
<ResponsiveDropdownMenuContent
|
||||
header={{
|
||||
title: "Load Config",
|
||||
description: "Load configuration from existing file",
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
loading={isLoadingEnv}
|
||||
className='w-full tablet:w-fit'
|
||||
onClick={() => {
|
||||
setIsLoadingEnv(true);
|
||||
|
||||
pickFile({
|
||||
accept: ".env",
|
||||
async onLoad(response) {
|
||||
await onLoadEnv(response);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</LoadingButton>
|
||||
<Button
|
||||
className='w-full tablet:w-fit'
|
||||
variant={"destructive"}
|
||||
@@ -206,11 +262,14 @@ export default function ConfiguratorPage() {
|
||||
Reset All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator className='mb-6' />
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
className='grid grid-cols-1 gap-8 tablet:px-2'
|
||||
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)}
|
||||
/>
|
||||
</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>
|
||||
</CardContent>
|
||||
</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
@@ -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;
|
||||
}
|
||||
|
||||
+15
-6
@@ -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 => {
|
||||
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
@@ -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(),
|
||||
}),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user