diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index 2fa68b6e..788407d5 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -36,6 +36,9 @@ export default async function RoutePage(props: PageParams<{ const dbItem = await db.query.database.findFirst({ where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)), + with: { + project: true + } }); if (!dbItem) { @@ -71,6 +74,12 @@ export default async function RoutePage(props: PageParams<{ .then((rows) => rows.length), ]); + + const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + if (!settings) { + notFound(); + } + const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; return ( @@ -88,7 +97,8 @@ export default async function RoutePage(props: PageParams<{ {dbItem.description} - diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index 74df5e0b..90ca43c2 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -1,12 +1,13 @@ import {NextResponse} from "next/server"; import {isUuidv4} from "@/utils/verify-uuid"; -import {uploadLocalPrivate} from "@/features/upload/private/upload.action"; +import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {v4 as uuidv4} from "uuid"; import {eventEmitter} from "../../../events/route"; import {db} from "@/db"; import {Backup} from "@/db/schema/06_database"; import {and, eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; +import {env} from "@/env.mjs"; export async function POST( request: Request, @@ -48,6 +49,9 @@ export async function POST( const database = await db.query.database.findFirst({ where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId), + with: { + project: true + } }); if (!database) { @@ -108,7 +112,19 @@ export async function POST( const fileName = `${uuid}.dump`; const buffer = Buffer.from(await file.arrayBuffer()); - const {success, message, filePath} = await uploadLocalPrivate(fileName, buffer); + 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); if (!success) { return NextResponse.json( diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index c2217968..09e559a0 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -18,7 +18,7 @@ export type Body = { databases: databaseAgent[] } - +// Function to test the get file url presigned local export async function GET(request: Request) { const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump") return Response.json({ diff --git a/portabase.config.ts b/portabase.config.ts index 2e5e6d27..28d833a1 100644 --- a/portabase.config.ts +++ b/portabase.config.ts @@ -13,6 +13,7 @@ export const PORTABASE_DEFAULT_SETTINGS = { "https://code.iconify.com", "https://cdn.iconify.design", "https://api.iconify.design", + ], STYLE_SRC: [ "'self'", @@ -31,6 +32,7 @@ export const PORTABASE_DEFAULT_SETTINGS = { "https://cdn.iconify.design", "https://code.iconify.com", "https://api.iconify.design", + "http://localhost:9000", ], FONT_SRC: [ "'self'", diff --git a/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx b/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx index 680f51ad..60f10264 100644 --- a/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx +++ b/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx @@ -7,6 +7,7 @@ import { useMutation } from "@tanstack/react-query"; import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/avatar/avatar.action"; import { useRouter } from "next/navigation"; import { User } from "@/db/schema/01_user"; +import {ChangeEvent} from "react"; export type AvatarWithUploadProps = { user: User; @@ -43,7 +44,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => { }, }); - const handleImageUpload = async (event: React.ChangeEvent) => { + const handleImageUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; if (!file.type.includes("image")) { diff --git a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx index 8da6a7e4..000c1397 100644 --- a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx @@ -7,13 +7,15 @@ import { eventUpdate } from "@/types/events"; import { backupColumns } from "@/features/dashboard/backup/columns"; import { restoreColumns } from "@/features/dashboard/restore/columns"; import { DataTable } from "@/components/wrappers/common/table/data-table"; -import {Backup, Database, Restoration} from "@/db/schema/06_database"; +import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/06_database"; +import {Setting} from "@/db/schema/00_setting"; export type DatabaseTabsProps = { + settings: Setting backups: Backup[]; restorations: Restoration[]; isAlreadyRestore: boolean; - database: Database; + database: DatabaseWith; }; export const DatabaseTabs = (props: DatabaseTabsProps) => { @@ -41,7 +43,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => { - + diff --git a/src/db/schema/06_database.ts b/src/db/schema/06_database.ts index aaba0889..066ad824 100644 --- a/src/db/schema/06_database.ts +++ b/src/db/schema/06_database.ts @@ -1,10 +1,10 @@ -import { pgTable, text, boolean, timestamp, uuid, integer, pgEnum, uniqueIndex } from "drizzle-orm/pg-core"; -import { Agent, agent } from "./07_agent"; -import { Project, project } from "./05_project"; -import { relations } from "drizzle-orm"; -import { dbmsEnum, statusEnum } from "./types"; -import { createSelectSchema } from "drizzle-zod"; -import { z } from "zod"; +import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum, uniqueIndex} from "drizzle-orm/pg-core"; +import {Agent, agent} from "./07_agent"; +import {Project, project} from "./05_project"; +import {relations} from "drizzle-orm"; +import {dbmsEnum, statusEnum} from "./types"; +import {createSelectSchema} from "drizzle-zod"; +import {z} from "zod"; export const database = pgTable("databases", { id: uuid("id").primaryKey().defaultRandom(), @@ -19,7 +19,7 @@ export const database = pgTable("databases", { updatedAt: timestamp("updated_at"), agentId: uuid("agent_id") .notNull() - .references(() => agent.id, { onDelete: "cascade" }), + .references(() => agent.id, {onDelete: "cascade"}), lastContact: timestamp("last_contact"), projectId: uuid("project_id") @@ -36,7 +36,7 @@ export const backup = pgTable( updatedAt: timestamp("updated_at"), databaseId: uuid("database_id") .notNull() - .references(() => database.id, { onDelete: "cascade" }), + .references(() => database.id, {onDelete: "cascade"}), }, // (table) => [uniqueIndex("database_id_status_unique").on(table.databaseId, table.status)] ); @@ -48,25 +48,25 @@ export const restoration = pgTable("restorations", { updatedAt: timestamp("updated_at"), backupId: uuid("backup_id") .notNull() - .references(() => backup.id, { onDelete: "cascade" }), - databaseId: uuid("database_id").references(() => database.id, { onDelete: "cascade" }), + .references(() => backup.id, {onDelete: "cascade"}), + databaseId: uuid("database_id").references(() => database.id, {onDelete: "cascade"}), }); -export const databaseRelations = relations(database, ({ one, many }) => ({ - agent: one(agent, { fields: [database.agentId], references: [agent.id] }), - project: one(project, { fields: [database.projectId], references: [project.id] }), +export const databaseRelations = relations(database, ({one, many}) => ({ + agent: one(agent, {fields: [database.agentId], references: [agent.id]}), + project: one(project, {fields: [database.projectId], references: [project.id]}), backups: many(backup), restorations: many(restoration), })); -export const backupRelations = relations(backup, ({ one, many }) => ({ - database: one(database, { fields: [backup.databaseId], references: [database.id] }), +export const backupRelations = relations(backup, ({one, many}) => ({ + database: one(database, {fields: [backup.databaseId], references: [database.id]}), restorations: many(restoration), })); -export const restorationRelations = relations(restoration, ({ one }) => ({ - backup: one(backup, { fields: [restoration.backupId], references: [backup.id] }), - database: one(database, { fields: [restoration.databaseId], references: [database.id] }), +export const restorationRelations = relations(restoration, ({one}) => ({ + backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}), + database: one(database, {fields: [restoration.databaseId], references: [database.id]}), })); export const databaseSchema = createSelectSchema(database); @@ -79,8 +79,9 @@ export const restorationSchema = createSelectSchema(restoration); export type Restoration = z.infer; export type DatabaseWith = Database & { - agent: Agent; - project: Project; - backups: Backup[]; - restorations: Restoration[]; + agent?: Agent | null; + project?: Project | null; + backups?: Backup[] | null; + restorations?: Restoration[] | null; }; + diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index d1723495..f871bc8b 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -12,18 +12,23 @@ import { import {Button} from "@/components/ui/button"; import {Download, MoreHorizontal, Trash2} from "lucide-react"; import {ReloadIcon} from "@radix-ui/react-icons"; -import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action"; +import { + getFileUrlPresignedLocal, + getFileUrlPresignedS3, + getFileUrlPreSignedS3Action +} from "@/features/upload/private/upload.action"; import {useMutation} from "@tanstack/react-query"; import {createRestorationAction, deleteBackupAction} from "@/features/dashboard/restore/restore.action"; import {toast} from "sonner"; import {useRouter} from "next/navigation"; import {StatusBadge} from "@/components/wrappers/common/status-badge"; -import {Backup} from "@/db/schema/06_database"; +import {Backup, DatabaseWith} from "@/db/schema/06_database"; import {formatFrenchDate} from "@/utils/date-formatting"; import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom"; +import {Setting} from "@/db/schema/00_setting"; -export function backupColumns(isAlreadyRestore: boolean): ColumnDef[] { +export function backupColumns(isAlreadyRestore: boolean, settings: Setting, database: DatabaseWith): ColumnDef[] { return [ { @@ -98,7 +103,19 @@ export function backupColumns(isAlreadyRestore: boolean): ColumnDef[] { }; const handleDownload = async (fileName: string) => { - const url = await getFileUrlPresignedLocal(fileName); + let url: string = ""; + if (settings.storage == "local") { + url = await getFileUrlPresignedLocal(fileName); + } else if (settings.storage == "s3") { + const data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`) + if (data?.data?.success) { + url = data.data.value ?? ""; + } else { + // @ts-ignore + const errorMessage = data?.data?.actionError?.message || "Failed to get file!"; + toast.error(errorMessage); + } + } window.open(url, "_self"); }; diff --git a/src/features/upload/private/upload.action.ts b/src/features/upload/private/upload.action.ts index e69d8d02..63d12f03 100644 --- a/src/features/upload/private/upload.action.ts +++ b/src/features/upload/private/upload.action.ts @@ -1,16 +1,25 @@ "use server"; -import { mkdir, writeFile } from "fs/promises"; +import {mkdir, writeFile} from "fs/promises"; import path from "path"; import * as fs from "node:fs"; -import { getServerUrl } from "@/utils/get-server-url"; +import {getServerUrl} from "@/utils/get-server-url"; +import {createPresignedUrlToDownload, saveFileInBucket} from "@/utils/s3-file-management"; +import crypto from "crypto"; +import {env} from "@/env.mjs"; +import {userAction} from "@/safe-actions"; +import {z} from "zod"; +import {ServerActionResult} from "@/types/action-type"; +import {Backup} from "@/db/schema/06_database"; +import {db} from "@/db"; +import * as drizzleDb from "@/db"; const privateLocalDir = "private/uploads/files/"; +const privateS3Dir = "backups/"; export async function uploadLocalPrivate(fileName: string, buffer: any) { try { - await mkdir(path.join(process.cwd(), privateLocalDir), { recursive: true }); - + await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true}); await writeFile(path.join(process.cwd(), privateLocalDir, fileName), buffer); return { @@ -24,10 +33,31 @@ export async function uploadLocalPrivate(fileName: string, buffer: any) { } } +export async function uploadS3Private(fileName: string, buffer: any, bucketName: string) { + try { + + await saveFileInBucket({ + bucketName, + fileName: `${privateS3Dir}${fileName}`, + file: buffer, + }); + + return { + success: true, + filePath: `${privateS3Dir}${fileName}`, + message: "File uploaded successfully", + }; + } catch (error) { + console.error("Error occurred:", error); + throw new Error("An error occurred while importing the private file"); + } +} + + export async function getFileUrlPresignedLocal(fileName: string) { try { const filePath = path.join(privateLocalDir, fileName); - await mkdir(path.join(process.cwd(), privateLocalDir), { recursive: true }); + await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true}); if (!fs.existsSync(filePath)) { console.error("File not found at:", filePath); @@ -43,3 +73,46 @@ export async function getFileUrlPresignedLocal(fileName: string) { throw error; } } + +export async function getFileUrlPresignedS3(fileName: string) { + try { + return await createPresignedUrlToDownload({ + bucketName: env.S3_BUCKET_NAME!, + fileName: fileName, + }); + } catch (error) { + throw error; + } +} + + +export const getFileUrlPreSignedS3Action = userAction.schema(z.string()).action(async ({parsedInput}): Promise> => { + try { + const url = await createPresignedUrlToDownload({ + bucketName: env.S3_BUCKET_NAME!, + fileName: parsedInput, + }); + return { + success: true, + value: url, + actionSuccess: { + message: "Successfully get url", + messageParams: {fileName: parsedInput}, + }, + }; + } catch (error) { + console.error("Error creating backup:", error); + return { + success: false, + actionError: { + message: "Failed to create url pre signed s3.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {fileName: parsedInput}, + }, + }; + } +}); + + + diff --git a/src/features/upload/public/upload.action.ts b/src/features/upload/public/upload.action.ts index 5b186c4f..8d2150a8 100644 --- a/src/features/upload/public/upload.action.ts +++ b/src/features/upload/public/upload.action.ts @@ -30,11 +30,13 @@ export const uploadImageAction = userAction.schema(z.instanceof(FormData)).actio let result: void | UploadedObjectInfo; const bucketName = "public-image-bucket"; - if (settings.storage === "local") { - result = await uploadLocal(fileName, buffer); - } else if (settings.storage === "s3") { - result = await uploadS3Compatible(bucketName, fileName, buffer); - } + // TODO : Do not delete + // if (settings.storage === "local") { + // result = await uploadLocal(fileName, buffer); + // } else if (settings.storage === "s3") { + // result = await uploadS3Compatible(bucketName, fileName, buffer); + // } + result = await uploadLocal(fileName, buffer); const url = getUrl(fileName, settings, bucketName); console.log(url); diff --git a/src/utils/s3-file-management.ts b/src/utils/s3-file-management.ts index e4472c4a..e47fb95f 100644 --- a/src/utils/s3-file-management.ts +++ b/src/utils/s3-file-management.ts @@ -23,16 +23,23 @@ async function getS3Client() { secretKey: settings.s3SecretAccessKey ?? "", }; - const s3Client = - env.NODE_ENV === "production" - ? new Minio.Client({ - ...baseConfig, - }) - : new Minio.Client({ - ...baseConfig, - port: Number(env.S3_PORT ?? 0), - useSSL: env.S3_USE_SSL === "true", - }); + // const s3Client = + // env.NODE_ENV === "production" + // ? new Minio.Client({ + // ...baseConfig, + // useSSL: env.S3_USE_SSL === "true", + // }) + // : new Minio.Client({ + // ...baseConfig, + // port: Number(env.S3_PORT ?? 0), + // useSSL: env.S3_USE_SSL === "true", + // }); + + const s3Client = new Minio.Client({ + ...baseConfig, + port: Number(env.S3_PORT ?? 0), + useSSL: env.S3_USE_SSL === "true", + }) return s3Client; } @@ -168,3 +175,30 @@ export async function createPublicBucket({bucketName}: { bucketName: string }) { console.error("Error creating bucket:", error); } } + +/** + * Generate a presigned URL for downloading a file from a private S3 bucket + * @param bucketName name of the bucket + * @param fileName name of the file + * @param expiry expiry time in seconds (default 1 hour) + * @returns presigned download URL + */ +export async function createPresignedUrlToDownload({ + bucketName, + fileName, + expiry = 60 * 60, // 1 hour + }: { + bucketName: string; + fileName: string; + expiry?: number; +}) { + const s3Client = await getS3Client(); + + // Optionally: ensure file exists + const fileExists = await checkFileExistsInBucket({ bucketName, fileName }); + if (!fileExists) { + throw new Error("File does not exist in the bucket."); + } + + return await s3Client.presignedGetObject(bucketName, fileName, expiry); +} \ No newline at end of file