fix: refactoring, adding express api.

This commit is contained in:
charlesgauthereau
2026-01-31 22:15:01 +01:00
parent 7c5d57ddc1
commit cc87cf68b5
33 changed files with 1028 additions and 142 deletions
@@ -75,7 +75,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
if (action === "download") {
console.log(inner.value)
const url = inner.value
if (typeof url === "string") {
window.open(url, "_self");
@@ -57,7 +57,7 @@ export const downloadBackupAction = userAction.schema(
}
};
console.log(input)
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
console.log(result);
@@ -106,8 +106,7 @@ export const ChannelPoliciesForm = ({
});
console.log(policiesToUpdate);
console.log(policiesToAdd);
const promises = kind === "notification"
? [
+6
View File
@@ -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();
}
+10
View File
@@ -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;
+145
View File
@@ -0,0 +1,145 @@
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {dispatchStorage} from "@/features/storages/dispatch";
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 {PassThrough} from "stream";
import fs from "node:fs";
import forge from "node-forge";
import crypto from "node:crypto";
import {pipeline} from "node:stream";
import { promisify } from "util";
export async function storeBackupFilesStream(
backup: Backup,
database: DatabaseWith,
fileStream: NodeJS.ReadableStream,
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: 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));
return [];
}
const storagePath = `backups/${database.project?.slug}/${fileName}`;
// const teeStreams = teeStreamSafe(fileStream, policies.length);
const teeStreams = teeStream(fileStream, policies.length);
const results = await Promise.all(
policies.map(async (policy, index) => {
const pass = teeStreams[index];
console.log("policy", index, policy.id)
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: teeStreams[index],
},
};
let result: StorageResult;
try {
result = policy.id
? await dispatchStorage(input, policy.id)
: await dispatchStorage(input, undefined, policy.storageChannelId);
} catch (err: any) {
pass.destroy(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;
})
);
console.log("result upload", 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;
}
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);
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
const pipelinePromise = promisify(pipeline);
function teeStream(stream: NodeJS.ReadableStream, n: number): PassThrough[] {
const taps = Array.from({ length: n }, () => new PassThrough());
taps.forEach(tap => pipelinePromise(stream, tap).catch(err => tap.destroy(err)));
return taps;
}
+104
View File
@@ -0,0 +1,104 @@
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 {saveStreamToTempFile} from "@/features/api/upload/helpers/file";
/**
* 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,
fileName: string
): Promise<StorageResult[]> {
const tmpPath = await saveStreamToTempFile(inputStream, fileName);
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) {
result = {success: false, provider: null, error: err.message};
}
console.log(result);
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);
return results;
}
+15
View File
@@ -0,0 +1,15 @@
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;
}
+120
View File
@@ -0,0 +1,120 @@
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 {createDecryptionStream, getFileExtension} from "./helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import uploadTempFileToProviders 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";
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);
await uploadTempFileToProviders(backup, database, decryptedStream, fileName);
await sendNotificationsBackupRestore(database, "success_backup");
eventEmitter.emit('modification', {update: true});
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,
});
}
});
export default router;
+3
View File
@@ -0,0 +1,3 @@
import { EventEmitter } from "events";
export const eventEmitter = new EventEmitter();
@@ -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) {
@@ -68,11 +77,15 @@ export async function getGoogleDrive(
const fileId = await resolveFilePath(client, input.data.path, config.folderId);
if (!fileId) return {success: false, provider: "google-drive", error: "File not found"};
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 +101,7 @@ export async function getGoogleDrive(
return {
success: true,
provider: "google-drive",
file: Buffer.from(res.data as ArrayBuffer),
file: stream,
url: url,
};
}
@@ -96,7 +109,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;
+55 -41
View File
@@ -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 }
+37 -39
View File
@@ -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;
@@ -32,72 +32,70 @@ async function ensureBucket(config: S3Config) {
export async function uploadS3(
config: S3Config,
input: { data: StorageUploadInput, metadata?: StorageMetaData }
input: { data: StorageUploadInput, metadata?: StorageMetaData }
): Promise<StorageResult> {
const client = await getS3Client(config);
console.log(client);
await ensureBucket(config);
const key = `${BASE_DIR}${input.data.path}`;
const file = input.data.file;
console.log(key)
let uploadStream: Readable;
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
uploadStream = Readable.from(file);
} else if ((file as any).pipe) {
uploadStream = file;
} else {
throw new 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);
console.log(result);
} 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 }): Promise<StorageResult> {
export async function deleteS3(config: S3Config, input: {
data: StorageDeleteInput,
metadata?: StorageMetaData
}): Promise<StorageResult> {
const client = await getS3Client(config);
const key = `${BASE_DIR}${input.data.path}`;
+6 -3
View File
@@ -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
View File
@@ -517,7 +517,6 @@ export const getActiveMember = async () => {
const member = await auth.api.getActiveMember({
headers: await headers(),
});
console.log(member);
return member as MemberWithUser;
} catch (e) {
+1
View File
@@ -1,4 +1,5 @@
import nodemailer from "nodemailer";
import {Server} from "./types"
export const createTransporter = (server: Server) => {
const portNumber = Number(server.port);
+1
View File
@@ -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
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -5,7 +5,7 @@ import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
import {retentionPolicy} from "@/db/schema/07_database";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {eventEmitter} from "../../../../app/api/events/route";
import {eventEmitter} from "@/features/shared/event";
export const retentionCleanTask = async () => {
+3 -3
View File
@@ -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({