mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge branch 'feature' into feat/refresh
# Conflicts: # app/api/agent/[agentId]/backup/route.ts # app/api/events/route.ts # src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx # src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts # src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx # src/lib/tasks/database/index.ts
This commit is contained in:
+1
-1
@@ -95,7 +95,7 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.useSSL"
|
||||
name="config.ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
|
||||
@@ -220,18 +220,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
||||
|
||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||
{action === "delete" && (
|
||||
// <ButtonWithLoading
|
||||
// type="button"
|
||||
// variant="destructive"
|
||||
// onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
// isPending={mutationDeleteEntireBackup.isPending}
|
||||
// disabled={mutationDeleteEntireBackup.isPending}
|
||||
// >
|
||||
// Delete entire backup
|
||||
// </ButtonWithLoading>
|
||||
|
||||
|
||||
<ButtonWithLoading
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
@@ -240,35 +229,6 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
||||
>
|
||||
Delete entire backup
|
||||
</ButtonWithLoading>
|
||||
|
||||
// <ButtonWithConfirm
|
||||
// title={"Delete entire backup"}
|
||||
// description={"Are you sure you want to delete this entire backup?"}
|
||||
// button={{
|
||||
// main: {
|
||||
// type: "button",
|
||||
// variant: "destructive",
|
||||
// text: "Delete entire backup",
|
||||
// },
|
||||
// confirm: {
|
||||
// className: "w-full",
|
||||
// text: "Delete",
|
||||
// icon: <Trash2/>,
|
||||
// variant: "destructive",
|
||||
// onClick: async () => {
|
||||
// mutationDeleteEntireBackup.mutateAsync()
|
||||
// },
|
||||
// },
|
||||
// cancel: {
|
||||
// className: "w-full",
|
||||
// text: "Cancel",
|
||||
// icon: <Trash2/>,
|
||||
// variant: "outline",
|
||||
// },
|
||||
// }}
|
||||
// isPending={mutationDeleteEntireBackup.isPending}
|
||||
// />
|
||||
|
||||
)}
|
||||
|
||||
{filteredBackupStorages.length > 0 && (
|
||||
|
||||
@@ -103,7 +103,6 @@ export const ChannelPoliciesForm = ({
|
||||
return existing &&
|
||||
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
|
||||
});
|
||||
|
||||
const promises = kind === "notification"
|
||||
? [
|
||||
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
|
||||
@@ -128,9 +127,9 @@ export const ChannelPoliciesForm = ({
|
||||
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
|
||||
return {success: true};
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Policies saved successfully");
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||
onSuccess: () => {
|
||||
toast.success("Policies saved successfully");
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
|
||||
},
|
||||
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
|
||||
});
|
||||
|
||||
@@ -4,13 +4,11 @@ import {ServerActionResult} from "@/types/action-type";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {getFileExtension} from "../../../../../../app/api/agent/[agentId]/backup/helpers";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||
import {z} from "zod";
|
||||
import {env} from "@/env.mjs";
|
||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||
import {getFileExtension} from "@/features/api/upload/helpers/file";
|
||||
|
||||
|
||||
export const uploadBackupAction = userAction
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {boolean, pgTable, uuid} from "drizzle-orm/pg-core";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
import {relations} from "drizzle-orm";
|
||||
import {database} from "@/db/schema/07_database";
|
||||
import {Backup, Database, database, Restoration, RetentionPolicy} from "@/db/schema/07_database";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
import {storageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Project} from "@/db/schema/06_project";
|
||||
import {AlertPolicy} from "@/db/schema/10_alert-policy";
|
||||
|
||||
export const storagePolicy = pgTable('storage_policy', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
@@ -31,3 +34,8 @@ export const storagePolicyRelations = relations(storagePolicy, ({one}) => ({
|
||||
|
||||
export const storagePolicySchema = createSelectSchema(storagePolicy);
|
||||
export type StoragePolicy = z.infer<typeof storagePolicySchema>;
|
||||
|
||||
|
||||
export type StoragePolicyWith = StoragePolicy & {
|
||||
storageChannel: StorageChannel;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {Request, Response, NextFunction} from "express";
|
||||
|
||||
export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
console.log(`[API - SERVICES - V1] Received ${req.method} request : ${req.url} at ${new Date().toISOString()}`);
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import express, { Router } from "express";
|
||||
import uploadRouter from "./upload";
|
||||
import { loggingMiddleware } from "@/features/api/middleware";
|
||||
|
||||
const router: Router = express.Router();
|
||||
|
||||
router.use(loggingMiddleware);
|
||||
router.use("/upload", uploadRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,134 @@
|
||||
import fs from "fs";
|
||||
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import type {StorageInput, StorageResult} from "@/features/storages/types";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import forge from "node-forge";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Save stream to a temporary file and upload to all storage providers in parallel.
|
||||
*/
|
||||
export default async function uploadTempFileToProviders(
|
||||
backup: Backup,
|
||||
database: DatabaseWith,
|
||||
// inputStream: NodeJS.ReadableStream,
|
||||
tmpPath: string,
|
||||
fileName: string
|
||||
): Promise<StorageResult[]> {
|
||||
|
||||
const stats = fs.statSync(tmpPath);
|
||||
const fileSize = stats.size;
|
||||
console.log(tmpPath);
|
||||
|
||||
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: settings.storageChannel.enabled,
|
||||
}]
|
||||
: [];
|
||||
|
||||
const enabledPolicies = database.storagePolicies?.filter(p => p.enabled) ?? [];
|
||||
const policies = enabledPolicies.length ? enabledPolicies : defaultPolicy;
|
||||
|
||||
if (!policies.length) {
|
||||
await db.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: "failed"}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
fs.existsSync(tmpPath) && fs.unlinkSync(tmpPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
const storagePath = `backups/${database.project?.slug}/${fileName}`;
|
||||
|
||||
const results = await Promise.all(
|
||||
policies.map(async (policy) => {
|
||||
const fileStream = fs.createReadStream(tmpPath);
|
||||
|
||||
const [backupStorage] = await db.insert(drizzleDb.schemas.backupStorage)
|
||||
.values({
|
||||
backupId: backup.id,
|
||||
storageChannelId: policy.storageChannelId,
|
||||
status: "pending",
|
||||
path: storagePath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const input: StorageInput = {
|
||||
action: "upload",
|
||||
data: {path: storagePath, file: fileStream, size: fileSize},
|
||||
};
|
||||
|
||||
let result: StorageResult;
|
||||
|
||||
try {
|
||||
result = policy.id
|
||||
? await dispatchStorage(input, policy.id)
|
||||
: await dispatchStorage(input, undefined, policy.storageChannelId);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
result = {success: false, provider: null, error: err.message};
|
||||
}
|
||||
await db.update(drizzleDb.schemas.backupStorage)
|
||||
.set(withUpdatedAt({status: result.success ? "success" : "failed"}))
|
||||
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const backupStatus = results.some(r => r.success) ? "success" : "failed";
|
||||
await db.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
fileSize: fileSize,
|
||||
status: backupStatus
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
fs.existsSync(tmpPath) && fs.unlinkSync(tmpPath);
|
||||
|
||||
console.log(results);
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
if (!backupStatus) {
|
||||
await sendNotificationsBackupRestore(database, "error_backup");
|
||||
}
|
||||
await sendNotificationsBackupRestore(database, "success_backup");
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
export function createDecryptionStream(
|
||||
encryptedAesKeyHex: string,
|
||||
ivHex: string
|
||||
) {
|
||||
const privateKeyPem = fs.readFileSync(
|
||||
"private/keys/server_private.pem",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
|
||||
const encryptedBytes = forge.util.hexToBytes(encryptedAesKeyHex);
|
||||
const aesKeyBytes = privateKey.decrypt(encryptedBytes, "RSA-OAEP", {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: {md: forge.md.sha256.create()},
|
||||
});
|
||||
|
||||
const aesKey = Buffer.from(aesKeyBytes, "binary");
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
|
||||
return crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {promisify} from "util";
|
||||
import {pipeline} from "stream";
|
||||
|
||||
const pipelineAsync = promisify(pipeline);
|
||||
const TMP_DIR = path.join(process.cwd(), "private/uploads/tmp");
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
|
||||
export async function saveStreamToTempFile(stream: NodeJS.ReadableStream, fileName: string): Promise<string> {
|
||||
const tmpPath = path.join(TMP_DIR, fileName);
|
||||
const writeStream = fs.createWriteStream(tmpPath);
|
||||
await pipelineAsync(stream, writeStream);
|
||||
return tmpPath;
|
||||
}
|
||||
|
||||
|
||||
export function getFileExtension(dbType: string) {
|
||||
switch (dbType) {
|
||||
case "postgresql":
|
||||
return ".dump";
|
||||
case "mysql":
|
||||
return ".sql";
|
||||
default:
|
||||
return ".dump";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import express, {Request, Response, Router} from "express";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import uploadTempFileToProviders, {createDecryptionStream} from "@/features/api/upload/helpers/common";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
import {getFileExtension, saveStreamToTempFile} from "@/features/api/upload/helpers/file";
|
||||
|
||||
const router: Router = express.Router();
|
||||
|
||||
// router.post("/:agentId", async (req: Request, res: Response) => {
|
||||
// try {
|
||||
// const agentId = req.params.agentId as string | undefined;
|
||||
// const generatedId = req.headers["x-generated-id"] as string | undefined;
|
||||
// const status = req.headers["x-status"] as string | undefined;
|
||||
// const encryptedAesKeyHex = req.headers["x-aes-key"] as string | undefined;
|
||||
// const ivHex = req.headers["x-iv"] as string | undefined;
|
||||
// const method = (req.headers["x-method"] as string) ?? "manual";
|
||||
// const extension = req.headers["x-extension"] as string | undefined;
|
||||
//
|
||||
//
|
||||
// if (!generatedId || !encryptedAesKeyHex || !ivHex || !agentId || !status) {
|
||||
// return res.status(400).json({error: "Missing required headers/params"});
|
||||
// }
|
||||
//
|
||||
// if (!isUuidv4(generatedId)) {
|
||||
// return res.status(400).json({error: "generatedId is not a valid UUID"});
|
||||
// }
|
||||
//
|
||||
// const agent = await db.query.agent.findFirst({
|
||||
// where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
// });
|
||||
//
|
||||
// if (!agent) {
|
||||
// return res.status(404).json({error: "Agent not found"});
|
||||
// }
|
||||
//
|
||||
//
|
||||
// const database = await db.query.database.findFirst({
|
||||
// where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
// with: {
|
||||
// project: true,
|
||||
// storagePolicies: true,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// if (!database) {
|
||||
// return res.status(404).json({error: "Database not found"});
|
||||
// }
|
||||
//
|
||||
// let backup: Backup | null | undefined = null;
|
||||
//
|
||||
// if (method === "automatic") {
|
||||
// [backup] = await db
|
||||
// .insert(drizzleDb.schemas.backup)
|
||||
// .values({
|
||||
// status: "ongoing",
|
||||
// databaseId: database.id,
|
||||
// })
|
||||
// .returning();
|
||||
// } else {
|
||||
// backup = await db.query.backup.findFirst({
|
||||
// where: and(
|
||||
// eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||
// eq(drizzleDb.schemas.backup.status, "ongoing")
|
||||
// ),
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// if (!backup) {
|
||||
// return res.status(404).json({error: "Backup not found"});
|
||||
// }
|
||||
//
|
||||
//
|
||||
// if (status === "success") {
|
||||
//
|
||||
// const decipher = createDecryptionStream(encryptedAesKeyHex, ivHex);
|
||||
//
|
||||
// const fileExt = extension || getFileExtension(database.dbms);
|
||||
// const fileName = `${uuidv4()}${fileExt}`;
|
||||
//
|
||||
// const decryptedStream = req.pipe(decipher);
|
||||
//
|
||||
// const tmpPath = await saveStreamToTempFile(decryptedStream, fileName);
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
// if (!tmpPath) {
|
||||
// return res.status(500).json({
|
||||
// error: "Unable to save tmp backup file",
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// uploadTempFileToProviders(backup, database, tmpPath, fileName);
|
||||
//
|
||||
// return res.json({success: true});
|
||||
// } else {
|
||||
// await db
|
||||
// .update(drizzleDb.schemas.backup)
|
||||
// .set(withUpdatedAt({status: 'failed'}))
|
||||
// .where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
//
|
||||
// eventEmitter.emit('modification', {update: true});
|
||||
//
|
||||
// await sendNotificationsBackupRestore(database, "error_backup");
|
||||
//
|
||||
// return res.status(200).json({
|
||||
// error: "Backup successfully updated with status failed",
|
||||
// });
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// console.error("Upload error:", err);
|
||||
// return res.status(500).json({
|
||||
// error: "Upload failed",
|
||||
// detail: err.message,
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
router.post("/:agentId", async (req: Request, res: Response) => {
|
||||
const agentId = req.params.agentId as string | undefined;
|
||||
const generatedId = req.headers["x-generated-id"] as string | undefined;
|
||||
const status = req.headers["x-status"] as string | undefined;
|
||||
const encryptedAesKeyHex = req.headers["x-aes-key"] as string | undefined;
|
||||
const ivHex = req.headers["x-iv"] as string | undefined;
|
||||
const method = (req.headers["x-method"] as string) ?? "manual";
|
||||
const extension = req.headers["x-extension"] as string | undefined;
|
||||
|
||||
if (!generatedId || !encryptedAesKeyHex || !ivHex || !agentId || !status) {
|
||||
return res.status(400).json({error: "Missing required headers/params"});
|
||||
}
|
||||
if (!isUuidv4(generatedId)) {
|
||||
return res.status(400).json({error: "generatedId is not a valid UUID"});
|
||||
}
|
||||
|
||||
const agent = await db.query.agent.findFirst({ where: eq(drizzleDb.schemas.agent.id, agentId) });
|
||||
if (!agent) return res.status(404).json({error: "Agent not found"});
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
with: { project: true, storagePolicies: true },
|
||||
});
|
||||
if (!database) return res.status(404).json({error: "Database not found"});
|
||||
|
||||
let backup: Backup | null = null;
|
||||
if (method === "automatic") {
|
||||
[backup] = await db.insert(drizzleDb.schemas.backup).values({ status: "ongoing", databaseId: database.id }).returning();
|
||||
} else {
|
||||
backup = await db.query.backup.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||
eq(drizzleDb.schemas.backup.status, "ongoing")
|
||||
),
|
||||
});
|
||||
}
|
||||
if (!backup) return res.status(404).json({error: "Backup not found"});
|
||||
|
||||
res.status(202).json({success: true, message: "Backup received, processing in background"});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (status === "success") {
|
||||
const decipher = createDecryptionStream(encryptedAesKeyHex!, ivHex!);
|
||||
const fileExt = extension || getFileExtension(database.dbms);
|
||||
const fileName = `${uuidv4()}${fileExt}`;
|
||||
const decryptedStream = req.pipe(decipher);
|
||||
const tmpPath = await saveStreamToTempFile(decryptedStream, fileName);
|
||||
|
||||
if (!tmpPath) throw new Error("Unable to save tmp backup file");
|
||||
|
||||
await uploadTempFileToProviders(backup!, database, tmpPath, fileName);
|
||||
} else {
|
||||
await db.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: 'failed'}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup!.id));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await sendNotificationsBackupRestore(database, "error_backup");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Background backup processing failed:", err);
|
||||
try {
|
||||
await db.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({status: 'failed'}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup!.id));
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export const eventEmitter = new EventEmitter();
|
||||
@@ -11,7 +11,7 @@ import * as drizzleDb from "@/db";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import crypto, {createHash} from "crypto";
|
||||
import {createHash} from "crypto";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import path from "path";
|
||||
|
||||
|
||||
@@ -1,36 +1,3 @@
|
||||
// import {drive_v3, google} from "googleapis";
|
||||
// import Drive = drive_v3.Drive;
|
||||
// import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
||||
//
|
||||
//
|
||||
// export async function getGoogleDriveClient(config: GoogleDriveConfig): Promise<Drive> {
|
||||
// const auth = new google.auth.JWT({
|
||||
// email: config.clientEmail,
|
||||
// key: config.privateKey?.replace(/\\n/g, "\n"),
|
||||
// scopes: ["https://www.googleapis.com/auth/drive"],
|
||||
// });
|
||||
//
|
||||
// return google.drive({
|
||||
// version: "v3",
|
||||
// auth,
|
||||
// });
|
||||
// }
|
||||
//
|
||||
//
|
||||
// export async function findFileByName(
|
||||
// drive: any,
|
||||
// name: string,
|
||||
// folderId: string
|
||||
// ): Promise<string | null> {
|
||||
// const res = await drive.files.list({
|
||||
// q: `name='${name}' and '${folderId}' in parents and trashed=false`,
|
||||
// fields: "files(id)",
|
||||
// pageSize: 1,
|
||||
// });
|
||||
//
|
||||
// return res.data.files?.[0]?.id ?? null;
|
||||
// }
|
||||
|
||||
import {drive_v3, google} from "googleapis";
|
||||
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
||||
import Drive = drive_v3.Drive;
|
||||
@@ -42,7 +9,6 @@ export async function getGoogleDriveClient(config: GoogleDriveConfig): Promise<D
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
config.clientId,
|
||||
config.clientSecret,
|
||||
// config.redirectUri
|
||||
baseUrl
|
||||
);
|
||||
|
||||
|
||||
@@ -24,18 +24,27 @@ export async function uploadGoogleDrive(
|
||||
? await ensureFolderPath(client, folderPath, config.folderId)
|
||||
: config.folderId;
|
||||
|
||||
|
||||
const existing = await findFileByName(client, fileName, folderId);
|
||||
if (existing) return {success: false, provider: "google-drive", error: "File already exists"};
|
||||
|
||||
let fileStream: Readable;
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
fileStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
fileStream = file as Readable;
|
||||
} else {
|
||||
throw new Error("Unsupported file type for streaming upload");
|
||||
}
|
||||
|
||||
await client.files.create({
|
||||
requestBody: {name: fileName, parents: [folderId]},
|
||||
media: {body: Readable.from(input.data.file as Buffer)},
|
||||
media: {body: fileStream},
|
||||
fields: "id",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
@@ -70,9 +79,11 @@ export async function getGoogleDrive(
|
||||
|
||||
const res = await client.files.get(
|
||||
{fileId, alt: "media", supportsAllDrives: true},
|
||||
{responseType: "arraybuffer"}
|
||||
{responseType: "stream"}
|
||||
);
|
||||
|
||||
const stream = res.data as Readable;
|
||||
|
||||
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
@@ -88,7 +99,7 @@ export async function getGoogleDrive(
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-drive",
|
||||
file: Buffer.from(res.data as ArrayBuffer),
|
||||
file: stream,
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
@@ -96,7 +107,7 @@ export async function getGoogleDrive(
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-drive",
|
||||
file: Buffer.from(res.data as ArrayBuffer),
|
||||
file: stream,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// export type GoogleDriveConfig = {
|
||||
// clientEmail: string;
|
||||
// privateKey: string;
|
||||
// folderId: string;
|
||||
// };
|
||||
|
||||
|
||||
export type GoogleDriveConfig = {
|
||||
clientId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
StorageProviderKind,
|
||||
StorageInput,
|
||||
StorageResult, StorageMetaData,
|
||||
StorageResult,
|
||||
} from '../types';
|
||||
|
||||
import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local';
|
||||
@@ -39,8 +39,6 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
||||
delete: deleteGoogleDrive,
|
||||
ping: pingGoogleDrive,
|
||||
}
|
||||
// gcs: null as any,
|
||||
// azure: null as any,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
|
||||
@@ -1,95 +1,109 @@
|
||||
"use server"
|
||||
import {mkdir, writeFile, unlink, readFile} from 'fs/promises';
|
||||
import {mkdir, unlink} from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../types';
|
||||
import fs from "node:fs";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {generateFileUrl} from "@/features/storages/helpers";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
const BASE_DIR = "/private/uploads/";
|
||||
|
||||
export async function uploadLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
input: { data: StorageUploadInput; metadata?: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir || BASE_DIR;
|
||||
const fullPath = path.join(process.cwd(), base, input.data.path);
|
||||
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
await mkdir(dir, {recursive: true});
|
||||
await writeFile(fullPath, input.data.file);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
response: "Unable to get url file"
|
||||
};
|
||||
try {
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(fullPath, input.data.file);
|
||||
} else if (file instanceof Readable) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writable = fs.createWriteStream(fullPath);
|
||||
file.pipe(writable);
|
||||
writable.on("finish", resolve);
|
||||
writable.on("error", reject);
|
||||
});
|
||||
} else {
|
||||
return { success: false, provider: "local", error: "Unsupported file type. Must be Buffer or ReadableStream" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 'local',
|
||||
url: url
|
||||
};
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return { success: false, provider: "local", response: "Unable to get URL" };
|
||||
}
|
||||
return { success: true, provider: "local", url };
|
||||
}
|
||||
|
||||
return { success: true, provider: "local" };
|
||||
} catch (err: any) {
|
||||
try { await unlink(fullPath); } catch {}
|
||||
return { success: false, provider: "local", error: err.message || "Upload failed" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 'local',
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export async function getLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
input: { data: StorageGetInput; metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir || BASE_DIR;
|
||||
const filePath = path.join(process.cwd(), base, input.data.path)
|
||||
const fileName = path.basename(input.data.path);
|
||||
|
||||
const file = await readFile(filePath);
|
||||
const filePath = path.join(process.cwd(), base, input.data.path);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("File not found at:", filePath);
|
||||
return ({
|
||||
return {
|
||||
success: false,
|
||||
provider: 'local',
|
||||
});
|
||||
provider: "local",
|
||||
error: "File not found",
|
||||
};
|
||||
}
|
||||
|
||||
let fileStream: fs.ReadStream | undefined;
|
||||
|
||||
try {
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
} catch (err: any) {
|
||||
console.error("Error creating read stream:", err);
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
response: "Unable to get url file"
|
||||
error: "Unable to generate signed URL",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: file,
|
||||
url: url,
|
||||
file: fileStream,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: file,
|
||||
file: fileStream,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function deleteLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageDeleteInput, metadata?: StorageMetaData }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Minio from "minio";
|
||||
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types";
|
||||
import {generateFileUrl} from "@/features/storages/helpers";
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
type S3Config = {
|
||||
endPointUrl: string;
|
||||
@@ -9,7 +9,7 @@ type S3Config = {
|
||||
secretKey: string;
|
||||
bucketName: string;
|
||||
port?: number;
|
||||
useSSL?: boolean;
|
||||
ssl?: boolean;
|
||||
};
|
||||
|
||||
async function getS3Client(config: S3Config) {
|
||||
@@ -19,7 +19,7 @@ async function getS3Client(config: S3Config) {
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
port: config.port ?? 443,
|
||||
useSSL: config.useSSL ?? true,
|
||||
useSSL: config.ssl ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,68 +40,56 @@ export async function uploadS3(
|
||||
await ensureBucket(config);
|
||||
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = input.data.file;
|
||||
|
||||
let uploadStream: Readable;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
uploadStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
uploadStream = file;
|
||||
} else {
|
||||
return {success: false, provider: "s3", error: "Unsupported file type for streaming upload"};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.statObject(config.bucketName, key);
|
||||
return {success: false, provider: "s3", error: "File already exists"};
|
||||
} catch {
|
||||
// continue if not found
|
||||
const result = await client.putObject(config.bucketName, key, uploadStream, input.data.size);
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "s3", error: err.message};
|
||||
}
|
||||
|
||||
await client.putObject(config.bucketName, key, input.data.file as Buffer);
|
||||
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "s3",
|
||||
response: "Unable to get url file"
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 's3',
|
||||
url: url
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: 's3',
|
||||
};
|
||||
|
||||
|
||||
return {success: true, provider: "s3"};
|
||||
}
|
||||
|
||||
export async function getS3(config: S3Config, input: {
|
||||
data: StorageGetInput,
|
||||
metadata: StorageMetaData
|
||||
}): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
|
||||
export async function getS3(
|
||||
config: S3Config,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getS3Client(config);
|
||||
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);
|
||||
|
||||
let presignedUrl: string | undefined;
|
||||
if (input.data.signedUrl) {
|
||||
presignedUrl = await client.presignedGetObject(config.bucketName, key, input.data.expiresInSeconds ?? 60);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "s3",
|
||||
file: buffer,
|
||||
file: fileStream as unknown as Buffer | Readable,
|
||||
url: presignedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function deleteS3(config: S3Config, input: {
|
||||
data: StorageDeleteInput,
|
||||
metadata?: StorageMetaData
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
export type StorageProviderKind =
|
||||
| 'local'
|
||||
| 's3'
|
||||
@@ -20,9 +22,10 @@ export type StorageMetaData = {
|
||||
|
||||
export interface StorageUploadInput {
|
||||
path: string;
|
||||
file: Buffer | Uint8Array;
|
||||
file: Readable | Buffer | Uint8Array;
|
||||
url?: boolean;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface StorageGetInput {
|
||||
@@ -37,7 +40,7 @@ export interface StorageDeleteInput {
|
||||
|
||||
export type StorageInput =
|
||||
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData}
|
||||
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData }
|
||||
| { action: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
|
||||
| { action: 'ping'; };
|
||||
|
||||
@@ -45,7 +48,7 @@ export interface StorageResult {
|
||||
success: boolean;
|
||||
provider: StorageProviderKind | null;
|
||||
url?: string;
|
||||
file?: Buffer;
|
||||
file?: Buffer | Readable;
|
||||
error?: string;
|
||||
response?: any;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import {Server} from "./types"
|
||||
|
||||
export const createTransporter = (server: Server) => {
|
||||
const portNumber = Number(server.port);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {createTransporter} from "@/lib/email/helpers";
|
||||
import {Payload} from "@/lib/email/types";
|
||||
|
||||
export const sendEmail = async (data: Payload) => {
|
||||
const settings = await db
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use server";
|
||||
|
||||
|
||||
type Payload = {
|
||||
export type Payload = {
|
||||
to: string;
|
||||
from?: string;
|
||||
subject: string;
|
||||
html: any;
|
||||
};
|
||||
|
||||
type Server = {
|
||||
export type Server = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
|
||||
@@ -19,9 +19,9 @@ async function getS3Client() {
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
endPoint: settings.s3EndPointUrl ?? "",
|
||||
accessKey: settings.s3AccessKeyId ?? "",
|
||||
secretKey: settings.s3SecretAccessKey ?? "",
|
||||
endPoint: settings?.s3EndPointUrl ?? "",
|
||||
accessKey: settings?.s3AccessKeyId ?? "",
|
||||
secretKey: settings?.s3SecretAccessKey ?? "",
|
||||
};
|
||||
|
||||
return new Minio.Client({
|
||||
|
||||
Reference in New Issue
Block a user