mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
chore: working on backup api for new storage system.
This commit is contained in:
@@ -3,7 +3,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
|||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {desc, isNotNull, isNull, not} from "drizzle-orm";
|
import {desc, eq, isNotNull, isNull, not} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||||
@@ -29,6 +29,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -39,7 +43,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<ChannelsSection kind={"storage"} organizations={organizations} channels={storageChannels}/>
|
<ChannelsSection defaultStorageChannelId={settings?.defaultStorageChannelId} kind={"storage"} organizations={organizations} channels={storageChannels}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import forge from "node-forge";
|
import forge from "node-forge";
|
||||||
import {EventPayload} from "@/features/notifications/types";
|
|
||||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
|
||||||
import {Database, DatabaseWith} from "@/db/schema/07_database";
|
|
||||||
|
|
||||||
|
|
||||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {env} from "@/env.mjs";
|
|||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {decryptedDump, getFileExtension} from "./helpers";
|
import {decryptedDump, getFileExtension} from "./helpers";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
|
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -25,6 +26,7 @@ export async function POST(
|
|||||||
{status: 400}
|
{status: 400}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
const agentId = (await params).agentId;
|
const agentId = (await params).agentId;
|
||||||
@@ -56,7 +58,8 @@ export async function POST(
|
|||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
with: {
|
with: {
|
||||||
project: true,
|
project: true,
|
||||||
alertPolicies: true
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,35 +128,41 @@ export async function POST(
|
|||||||
const uuid = uuidv4();
|
const uuid = uuidv4();
|
||||||
const fileName = `${uuid}${fileExtension}`;
|
const fileName = `${uuid}${fileExtension}`;
|
||||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
|
||||||
if (!settings) {
|
|
||||||
throw new Error("System settings not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let success: boolean, message: string, filePath: string;
|
|
||||||
|
|
||||||
const result =
|
|
||||||
settings.storage === "local"
|
|
||||||
? await uploadLocalPrivate(fileName, buffer)
|
|
||||||
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
|
||||||
|
|
||||||
({success, message, filePath} = result);
|
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
// if (!settings) {
|
||||||
|
// throw new Error("System settings not found.");
|
||||||
|
// }
|
||||||
|
|
||||||
if (!success) {
|
// let success: boolean, message: string, filePath: string;
|
||||||
return NextResponse.json(
|
//
|
||||||
{error: message},
|
// const result =
|
||||||
{status: 500}
|
// settings.storage === "local"
|
||||||
);
|
// ? await uploadLocalPrivate(fileName, buffer)
|
||||||
}
|
// : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
|
||||||
|
//
|
||||||
|
// ({success, message, filePath} = result);
|
||||||
|
//
|
||||||
|
// if (!success) {
|
||||||
|
// return NextResponse.json(
|
||||||
|
// {error: message},
|
||||||
|
// {status: 500}
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
await db
|
await storeBackupFiles(backup, database, buffer, fileName)
|
||||||
.update(drizzleDb.schemas.backup)
|
|
||||||
.set(withUpdatedAt({
|
//
|
||||||
file: fileName,
|
// await db
|
||||||
fileSize: fileSizeBytes,
|
// .update(drizzleDb.schemas.backup)
|
||||||
status: 'success',
|
// .set(withUpdatedAt({
|
||||||
}))
|
// file: fileName,
|
||||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
// fileSize: fileSizeBytes,
|
||||||
|
// status: 'success',
|
||||||
|
// }))
|
||||||
|
// .where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
|||||||
+55
-35
@@ -42,6 +42,7 @@ export const ChannelAddEditModal = ({
|
|||||||
}: ChannelAddModalProps) => {
|
}: ChannelAddModalProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [openInternal, setOpen] = useState(open);
|
const [openInternal, setOpen] = useState(open);
|
||||||
|
const isLocalSystem = channel?.provider == "local";
|
||||||
|
|
||||||
const isCreate = !Boolean(channel);
|
const isCreate = !Boolean(channel);
|
||||||
|
|
||||||
@@ -82,46 +83,65 @@ export const ChannelAddEditModal = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div>
|
<div>
|
||||||
{adminView ?
|
<>
|
||||||
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
{!isLocalSystem ? (
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<>
|
||||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
{adminView ?
|
||||||
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
||||||
</TabsList>
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsContent className="h-full justify-between" value="configuration">
|
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
||||||
<ChannelForm
|
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
||||||
kind={kind}
|
</TabsList>
|
||||||
adminView={adminView}
|
<TabsContent className="h-full justify-between" value="configuration">
|
||||||
defaultValues={channel}
|
<ChannelForm
|
||||||
organization={organization}
|
kind={kind}
|
||||||
onSuccessAction={() => {
|
adminView={adminView}
|
||||||
onOpenChangeAction?.(false)
|
defaultValues={channel}
|
||||||
setOpen(false);
|
organization={organization}
|
||||||
}}
|
onSuccessAction={() => {
|
||||||
/>
|
onOpenChangeAction?.(false)
|
||||||
</TabsContent>
|
setOpen(false);
|
||||||
<TabsContent className="h-full justify-between" value="organizations">
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-full justify-between" value="organizations">
|
||||||
|
<ChannelOrganisationForm
|
||||||
|
defaultValues={channel}
|
||||||
|
kind={kind}
|
||||||
|
organizations={organizations}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<ChannelForm
|
||||||
|
kind={kind}
|
||||||
|
adminView={adminView}
|
||||||
|
defaultValues={channel}
|
||||||
|
organization={organization}
|
||||||
|
onSuccessAction={() => {
|
||||||
|
onOpenChangeAction?.(false)
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
:
|
||||||
|
|
||||||
|
<>
|
||||||
<ChannelOrganisationForm
|
<ChannelOrganisationForm
|
||||||
defaultValues={channel}
|
defaultValues={channel}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
organizations={organizations}
|
organizations={organizations}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</>
|
||||||
</Tabs>
|
}
|
||||||
:
|
|
||||||
<>
|
|
||||||
<ChannelForm
|
</>
|
||||||
kind={kind}
|
|
||||||
adminView={adminView}
|
|
||||||
defaultValues={channel}
|
|
||||||
organization={organization}
|
|
||||||
onSuccessAction={() => {
|
|
||||||
onOpenChangeAction?.(false)
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
+7
-4
@@ -33,6 +33,7 @@ export const EditChannelButton = ({
|
|||||||
}: EditChannelButtonProps) => {
|
}: EditChannelButtonProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const isLocalSystem = channel.provider == "local";
|
||||||
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -73,10 +74,12 @@ export const EditChannelButton = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Switch checked={channel.enabled} onCheckedChange={async () => {
|
|
||||||
await mutation.mutateAsync(!channel.enabled)
|
{!isLocalSystem && (
|
||||||
}}
|
<Switch checked={channel.enabled} onCheckedChange={async () => {
|
||||||
/>
|
await mutation.mutateAsync(!channel.enabled)
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
<ChannelAddEditModal
|
<ChannelAddEditModal
|
||||||
kind={kind}
|
kind={kind}
|
||||||
organizations={organizations}
|
organizations={organizations}
|
||||||
|
|||||||
+18
-7
@@ -21,13 +21,17 @@ export type ChannelCardProps = {
|
|||||||
organizations?: OrganizationWithMembers[];
|
organizations?: OrganizationWithMembers[];
|
||||||
adminView?: boolean;
|
adminView?: boolean;
|
||||||
kind?: ChannelKind;
|
kind?: ChannelKind;
|
||||||
|
defaultStorageChannelId?: string | null | undefined
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const ChannelCard = (props: ChannelCardProps) => {
|
export const ChannelCard = (props: ChannelCardProps) => {
|
||||||
const {data, organization, kind, adminView} = props;
|
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
|
||||||
|
|
||||||
const isOwned = data.organizationId ? true : !organization;
|
const isOwned = data.organizationId ? true : !organization;
|
||||||
const isLocalSystem = data.provider == "local";
|
const isLocalSystem = data.provider == "local";
|
||||||
|
|
||||||
@@ -47,11 +51,16 @@ export const ChannelCard = (props: ChannelCardProps) => {
|
|||||||
<Badge variant="secondary" className="text-xs font-mono">
|
<Badge variant="secondary" className="text-xs font-mono">
|
||||||
{data.provider}
|
{data.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{isDefaultSystemStorage && (
|
||||||
|
<Badge variant="secondary" className="text-xs font-mono">
|
||||||
|
default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{kind && (
|
{kind && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(isOwned && !isLocalSystem) && (
|
{(isOwned) && (
|
||||||
<>
|
<>
|
||||||
<EditChannelButton
|
<EditChannelButton
|
||||||
organizations={props.organizations}
|
organizations={props.organizations}
|
||||||
@@ -60,11 +69,13 @@ export const ChannelCard = (props: ChannelCardProps) => {
|
|||||||
channel={data}
|
channel={data}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
/>
|
/>
|
||||||
<DeleteChannelButton
|
{!isLocalSystem && (
|
||||||
kind={kind}
|
<DeleteChannelButton
|
||||||
organizationId={organization?.id}
|
kind={kind}
|
||||||
channelId={data.id}
|
organizationId={organization?.id}
|
||||||
/>
|
channelId={data.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+33
-1
@@ -3,6 +3,7 @@ import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/compon
|
|||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
import {PasswordInput} from "@/components/ui/password-input";
|
import {PasswordInput} from "@/components/ui/password-input";
|
||||||
|
import {Switch} from "@/components/ui/switch";
|
||||||
|
|
||||||
|
|
||||||
type StorageS3FormProps = {
|
type StorageS3FormProps = {
|
||||||
@@ -59,12 +60,43 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Bucket name</FormLabel>
|
<FormLabel>Bucket name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput {...field} placeholder="portabase-dev"/>
|
<Input {...field} placeholder="portabase-dev"/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" placeholder="443" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.useSSL"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Use SSL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channel
|
|||||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||||
|
|
||||||
type ChannelsSectionProps = {
|
type ChannelsSectionProps = {
|
||||||
channels: NotificationChannelWith[] | StorageChannelWith[]
|
channels: NotificationChannelWith[] | StorageChannelWith[],
|
||||||
organizations: OrganizationWithMembers[]
|
organizations: OrganizationWithMembers[],
|
||||||
kind: ChannelKind;
|
kind: ChannelKind,
|
||||||
|
defaultStorageChannelId?: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChannelsSection = ({
|
export const ChannelsSection = ({
|
||||||
organizations,
|
organizations,
|
||||||
channels,
|
channels,
|
||||||
kind
|
kind,
|
||||||
|
defaultStorageChannelId
|
||||||
}: ChannelsSectionProps) => {
|
}: ChannelsSectionProps) => {
|
||||||
|
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
@@ -41,6 +43,7 @@ export const ChannelsSection = ({
|
|||||||
adminView={true}
|
adminView={true}
|
||||||
organizations={organizations}
|
organizations={organizations}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
|
defaultStorageChannelId={defaultStorageChannelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+3
-1
@@ -14,6 +14,7 @@ import * as alertPolicy from "./schema/10_alert-policy";
|
|||||||
import * as notificationLog from "./schema/11_notification-log";
|
import * as notificationLog from "./schema/11_notification-log";
|
||||||
import * as storageChannel from "./schema/12_storage-channel";
|
import * as storageChannel from "./schema/12_storage-channel";
|
||||||
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||||
|
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||||
|
|
||||||
|
|
||||||
import {Pool} from "pg";
|
import {Pool} from "pg";
|
||||||
@@ -44,7 +45,8 @@ export const schemas = {
|
|||||||
...alertPolicy,
|
...alertPolicy,
|
||||||
...notificationLog,
|
...notificationLog,
|
||||||
...storageChannel,
|
...storageChannel,
|
||||||
...storagePolicy
|
...storagePolicy,
|
||||||
|
...backupStorage
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import {createSelectSchema} from "drizzle-zod";
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
import {storageChannel} from "@/db/schema/12_storage-channel";
|
import {storageChannel} from "@/db/schema/12_storage-channel";
|
||||||
|
import {relations} from "drizzle-orm";
|
||||||
|
import {agent} from "@/db/schema/08_agent";
|
||||||
|
import {project} from "@/db/schema/06_project";
|
||||||
|
import {alertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
import {storagePolicy} from "@/db/schema/13_storage-policy";
|
||||||
|
import {backup, database, restoration, retentionPolicy} from "@/db/schema/07_database";
|
||||||
|
|
||||||
export const setting = pgTable("settings", {
|
export const setting = pgTable("settings", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -23,5 +29,10 @@ export const setting = pgTable("settings", {
|
|||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const settingRelations = relations(setting, ({one, many}) => ({
|
||||||
|
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
export const settingSchema = createSelectSchema(setting);
|
export const settingSchema = createSelectSchema(setting);
|
||||||
export type Setting = z.infer<typeof settingSchema>;
|
export type Setting = z.infer<typeof settingSchema>;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import * as drizzleDb from '@/db';
|
|||||||
import {db} from '@/db';
|
import {db} from '@/db';
|
||||||
import type {StorageInput, StorageProviderKind, StorageResult,} from './types';
|
import type {StorageInput, StorageProviderKind, StorageResult,} from './types';
|
||||||
import {dispatchViaProvider} from "@/features/storages/providers";
|
import {dispatchViaProvider} from "@/features/storages/providers";
|
||||||
|
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||||
|
import {Json} from "drizzle-zod";
|
||||||
|
|
||||||
export async function dispatchStorage(
|
export async function dispatchStorage(
|
||||||
input: StorageInput,
|
input: StorageInput,
|
||||||
@@ -13,34 +15,84 @@ export async function dispatchStorage(
|
|||||||
organizationId?: string
|
organizationId?: string
|
||||||
): Promise<StorageResult> {
|
): Promise<StorageResult> {
|
||||||
try {
|
try {
|
||||||
if (!channelId) {
|
|
||||||
|
let channel: StorageChannel | null = null;
|
||||||
|
|
||||||
|
if (policyId) {
|
||||||
|
const policyDb = await db.query.storagePolicy.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.storagePolicy.id, policyId),
|
||||||
|
with: {
|
||||||
|
storageChannel: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!policyDb || !policyDb.storageChannel) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: null,
|
||||||
|
error: "Policy or associated channel not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policyDb.enabled || !policyDb.storageChannel.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: policyDb.storageChannel.provider as any,
|
||||||
|
error: "Policy or channel is disabled",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
channel = {
|
||||||
|
...policyDb.storageChannel,
|
||||||
|
config: policyDb.storageChannel.config as Json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
const fetchedChannel = await db.query.storageChannel.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.storageChannel.id, channelId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchedChannel) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: null,
|
||||||
|
error: "Channel not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
channel = {
|
||||||
|
...fetchedChannel,
|
||||||
|
config: fetchedChannel.config as Json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
provider: null,
|
provider: null,
|
||||||
error: 'No storage channel provided',
|
error: "No valid channel to dispatch on storage",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await db.query.storageChannel.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.storageChannel.id, channelId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!channel || !channel.enabled) {
|
if (!channel.enabled) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
provider: channel?.provider as StorageProviderKind,
|
provider: null,
|
||||||
error: 'Storage channel not found or disabled',
|
error: "Channel not active",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return await dispatchViaProvider(
|
return await dispatchViaProvider(
|
||||||
channel.provider as StorageProviderKind,
|
channel.provider as StorageProviderKind,
|
||||||
channel.config,
|
channel.config,
|
||||||
input
|
input
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,10 +1,106 @@
|
|||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
import { Backup, DatabaseWith } from "@/db/schema/07_database";
|
||||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
import { dispatchStorage } from "@/features/storages/dispatch";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import type { StorageInput, StorageResult } from "@/features/storages/types";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { withUpdatedAt } from "@/db/utils";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
function computeChecksum(buffer: Buffer): string {
|
||||||
|
return createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeBackupFiles(
|
||||||
|
backup: Backup,
|
||||||
|
database: DatabaseWith,
|
||||||
|
file: Buffer,
|
||||||
|
fileName: string
|
||||||
|
): Promise<StorageResult[]> {
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
with: { storageChannel: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPolicy = settings?.storageChannel
|
||||||
|
? [{
|
||||||
|
id: null,
|
||||||
|
storageChannelId: settings.storageChannel.id,
|
||||||
|
enabled: true
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log(database.storagePolicies);
|
||||||
|
|
||||||
|
const policies = (database.storagePolicies?.filter(p => p.enabled) || defaultPolicy);
|
||||||
|
|
||||||
|
console.log("Policies", policies);
|
||||||
|
|
||||||
|
|
||||||
export async function storeFileBackup(database: DatabaseWith, file: Buffer) {
|
if (!policies.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${database.project?.slug}/${fileName}`;
|
||||||
|
const size = file.length;
|
||||||
|
const checksum = computeChecksum(file);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
policies.map(async policy => {
|
||||||
|
const [backupStorage] = await db
|
||||||
|
.insert(drizzleDb.schemas.backupStorage)
|
||||||
|
.values({
|
||||||
|
backupId: backup.id,
|
||||||
|
storageChannelId: policy.storageChannelId,
|
||||||
|
status: "pending",
|
||||||
|
path,
|
||||||
|
size,
|
||||||
|
checksum,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
}
|
const input: StorageInput = {
|
||||||
|
action: "upload",
|
||||||
|
data: { path, file },
|
||||||
|
};
|
||||||
|
|
||||||
|
// const result = await dispatchStorage(input, policy.id);
|
||||||
|
let result: StorageResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (policy.id){
|
||||||
|
result = await dispatchStorage(input, policy.id);
|
||||||
|
}else{
|
||||||
|
result = await dispatchStorage(input, undefined, policy.storageChannelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
result = {
|
||||||
|
success: false,
|
||||||
|
provider: null,
|
||||||
|
error: err instanceof Error ? err.message : "Unknown error"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backupStorage)
|
||||||
|
.set(withUpdatedAt({ status: result.success ? "success" : "failed" }))
|
||||||
|
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
const backupStatus = results.some(r => r.success) ? "success" : "failed";
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({ status: backupStatus }))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import {uploadLocal, getLocal, deleteLocal} from './local';
|
import {uploadLocal, getLocal, deleteLocal} from './local';
|
||||||
|
import {deleteS3, getS3, uploadS3} from "@/features/storages/providers/s3";
|
||||||
|
|
||||||
type ProviderHandler = {
|
type ProviderHandler = {
|
||||||
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
||||||
@@ -18,11 +19,11 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
|||||||
get: getLocal,
|
get: getLocal,
|
||||||
delete: deleteLocal,
|
delete: deleteLocal,
|
||||||
},
|
},
|
||||||
// s3: {
|
s3: {
|
||||||
// upload: uploadS3,
|
upload: uploadS3,
|
||||||
// get: getS3,
|
get: getS3,
|
||||||
// delete: deleteS3,
|
delete: deleteS3,
|
||||||
// },
|
},
|
||||||
// gcs: null as any,
|
// gcs: null as any,
|
||||||
// azure: null as any,
|
// azure: null as any,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export async function uploadLocal(
|
|||||||
const base = config.baseDir || BASE_DIR;
|
const base = config.baseDir || BASE_DIR;
|
||||||
const fullPath = path.join(process.cwd(), base, input.data.path);
|
const fullPath = path.join(process.cwd(), base, input.data.path);
|
||||||
|
|
||||||
await mkdir(fullPath, {recursive: true});
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(fullPath, input.data.file);
|
await writeFile(fullPath, input.data.file);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as Minio from "minio";
|
||||||
|
import {StorageDeleteInput, StorageGetInput, StorageResult, StorageUploadInput} from "../types";
|
||||||
|
|
||||||
|
type S3Config = {
|
||||||
|
endPointUrl: string;
|
||||||
|
accessKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
bucketName: string;
|
||||||
|
port?: number;
|
||||||
|
useSSL?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getS3Client(config: S3Config) {
|
||||||
|
return new Minio.Client({
|
||||||
|
endPoint: config.endPointUrl,
|
||||||
|
accessKey: config.accessKey,
|
||||||
|
secretKey: config.secretKey,
|
||||||
|
port: config.port ?? 443,
|
||||||
|
useSSL: config.useSSL ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_DIR = "backups/";
|
||||||
|
|
||||||
|
|
||||||
|
async function ensureBucket(config: S3Config) {
|
||||||
|
const client = await getS3Client(config);
|
||||||
|
const exists = await client.bucketExists(config.bucketName);
|
||||||
|
if (!exists) await client.makeBucket(config.bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadS3(config: S3Config, input: { data: StorageUploadInput }): Promise<StorageResult> {
|
||||||
|
const client = await getS3Client(config);
|
||||||
|
await ensureBucket(config);
|
||||||
|
|
||||||
|
const key = `${BASE_DIR}${input.data.path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.statObject(config.bucketName, key);
|
||||||
|
return {success: false, provider: "s3", error: "File already exists"};
|
||||||
|
} catch {
|
||||||
|
// continue if not found
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.putObject(config.bucketName, key, input.data.file as Buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "s3",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getS3(config: S3Config, input: { data: StorageGetInput }): Promise<StorageResult> {
|
||||||
|
const client = await getS3Client(config);
|
||||||
|
|
||||||
|
// const key = input.data.path;
|
||||||
|
const key = `${BASE_DIR}${input.data.path}`;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.statObject(config.bucketName, key);
|
||||||
|
} catch {
|
||||||
|
return {success: false, provider: "s3", error: "File not found"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const presignedUrl = await client.presignedGetObject(config.bucketName, key, 60);
|
||||||
|
|
||||||
|
const fileStream = await client.getObject(config.bucketName, key);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of fileStream) chunks.push(chunk as Buffer);
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "s3",
|
||||||
|
file: buffer,
|
||||||
|
url: presignedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteS3(config: S3Config, input: { data: StorageDeleteInput }): Promise<StorageResult> {
|
||||||
|
const client = await getS3Client(config);
|
||||||
|
// const key = input.data.path;
|
||||||
|
const key = `${BASE_DIR}${input.data.path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.removeObject(config.bucketName, key);
|
||||||
|
return {success: true, provider: "s3"};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {success: false, provider: "s3", error: err.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export type StorageProviderKind =
|
export type StorageProviderKind =
|
||||||
| 'local'
|
| 'local'
|
||||||
// | 's3'
|
| 's3'
|
||||||
;
|
;
|
||||||
|
|
||||||
export type StorageAction =
|
export type StorageAction =
|
||||||
|
|||||||
Reference in New Issue
Block a user