Compare commits

..
13 Commits
38 changed files with 2192 additions and 2430 deletions
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.2.4 version: 1.2.5-rc.5
date-released: "2026-01-29" date-released: "2026-02-01"
+1 -8
View File
@@ -1,7 +1,6 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {Backup} from "@/db/schema/07_database"; import {Backup} from "@/db/schema/07_database";
@@ -10,6 +9,7 @@ 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"; import {storeBackupFiles} from "@/features/storages/helpers";
import {eventEmitter} from "@/features/shared/event";
export async function POST( export async function POST(
request: Request, request: Request,
@@ -35,8 +35,6 @@ export async function POST(
const method = formData.get("method") as string | null; const method = formData.get("method") as string | null;
if (!generatedId || !isUuidv4(generatedId)) { if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid UUID"}, {error: "generatedId is not a valid UUID"},
@@ -116,7 +114,6 @@ export async function POST(
} }
if (!file) { if (!file) {
return NextResponse.json( return NextResponse.json(
{error: "File is required for successful backup"}, {error: "File is required for successful backup"},
@@ -132,9 +129,6 @@ export async function POST(
const buffer = Buffer.from(await decryptedFile.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
console.log(extension)
const storageResults = await storeBackupFiles(backup, database, buffer, fileName) const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
@@ -173,4 +167,3 @@ export async function POST(
); );
} }
} }
+1 -1
View File
@@ -1,10 +1,10 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {eventEmitter} from "@/features/shared/event";
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string
+1 -1
View File
@@ -1,12 +1,12 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {handleDatabases} from "./helpers"; import {handleDatabases} from "./helpers";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
export type databaseAgent = { export type databaseAgent = {
name: string, name: string,
+2 -1
View File
@@ -2,8 +2,9 @@ import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth"; import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
export const eventEmitter = new EventEmitter(); // export const eventEmitter = new EventEmitter();
export async function GET(request: Request) { export async function GET(request: Request) {
+3 -4
View File
@@ -35,10 +35,9 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
if (!result.success) { if (!result.success) {
return NextResponse.json({error: "Enable to get file from privided storage channel, an error occurred !"}) return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
} }
const fileName = path.basename(pathFromUrl); const fileName = path.basename(pathFromUrl);
const crypto = require('crypto'); const crypto = require('crypto');
@@ -58,14 +57,14 @@ export async function GET(
); );
} }
if (!result.file || !Buffer.isBuffer(result.file)) { if (!result.file || !(result.file instanceof Readable)) {
return NextResponse.json( return NextResponse.json(
{error: "Invalid file payload"}, {error: "Invalid file payload"},
{status: 500} {status: 500}
); );
} }
const fileStream = Readable.from(result.file); const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
+2 -16
View File
@@ -26,20 +26,6 @@ export async function GET(
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404}) return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
} }
// const settings = await db.query.setting.findFirst({
// where: eq(drizzleDb.schemas.setting.name, "system"),
// with: {
// storageChannel: true
// }
// });
//
// if (!settings || !settings.storageChannel) {
// return NextResponse.json({error: "Unable to get settings or no default storage channel"});
// }
const ext = fileName.split(".").pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const contentType = const contentType =
ext === "png" ext === "png"
@@ -69,7 +55,7 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId); const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !Buffer.isBuffer(result.file)) { if (!result.file || !(result.file instanceof Readable)) {
console.error(`An error occurred while getting file :`, result); console.error(`An error occurred while getting file :`, result);
return NextResponse.json( return NextResponse.json(
{error: "Invalid file payload"}, {error: "Invalid file payload"},
@@ -77,7 +63,7 @@ export async function GET(
); );
} }
const fileStream = Readable.from(result.file); const fileStream = Readable.from(result.file as Readable);
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
+5 -5
View File
@@ -3,11 +3,11 @@ name: portabase-prod
services: services:
app: app:
# build: build:
# context: . context: .
# dockerfile: docker/dockerfile/Dockerfile dockerfile: docker/dockerfile/Dockerfile
# target: prod target: prod
image: solucetechnologies/portabase:latest # image: solucetechnologies/portabase:latest
ports: ports:
- '8887:80' - '8887:80'
environment: environment:
+8 -2
View File
@@ -62,7 +62,6 @@ RUN pnpm run build
FROM base AS prod FROM base AS prod
WORKDIR /app WORKDIR /app
@@ -70,6 +69,9 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -81,14 +83,18 @@ RUN mkdir -p /app/private/uploads
RUN chown -R nextjs:nodejs /app/private RUN chown -R nextjs:nodejs /app/private
RUN chown -R nextjs:nodejs /app/public RUN chown -R nextjs:nodejs /app/public
COPY --from=builder /app/next.config.ts ./ COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/server ./server
COPY --from=builder /app/src ./src
COPY --from=builder /app/portabase.config.ts ./ COPY --from=builder /app/portabase.config.ts ./
COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db COPY --chown=nextjs:nodejs src/db ./src/db
COPY --from=deps /app/node_modules ./node_modules
USER root USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
+2 -2
View File
@@ -7,8 +7,8 @@ else
echo "[WARN] No TZ provided, using default container timezone" echo "[WARN] No TZ provided, using default container timezone"
fi fi
pnpm run start
node server.js #node server.js
exec "$@" exec "$@"
-1
View File
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
bodySizeLimit: "10gb", bodySizeLimit: "10gb",
}, },
proxyClientMaxBodySize: '10gb', proxyClientMaxBodySize: '10gb',
}, },
async headers() { async headers() {
return [ return [
+9 -5
View File
@@ -1,11 +1,12 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.4", "version": "1.2.5-rc.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "NODE_ENV=development tsx watch server/server.ts",
"build": "next build --experimental-build-mode compile", "build": "next build --experimental-build-mode compile",
"start": "next start", "start": "NODE_ENV=production node server/server.js",
"lint": "next lint", "lint": "next lint",
"email": "email dev --dir ./src/components/emails", "email": "email dev --dir ./src/components/emails",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
@@ -47,6 +48,7 @@
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1", "@tanstack/react-query": "^5.76.1",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
@@ -62,12 +64,13 @@
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^5.2.1",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"motion": "^12.23.24", "motion": "^12.23.24",
"next": "16.0.10", "next": "16.1.5",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@@ -111,8 +114,9 @@
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2", "@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2", "@zenstackhq/tanstack-query": "^2.14.2",
"baseline-browser-mapping": "^2.8.32", "baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.1",
"esbuild": "^0.27.2",
"eslint": "^9.39.0", "eslint": "^9.39.0",
"eslint-config-next": "^16.0.1", "eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-tailwindcss": "^3.18.0",
+1677 -2239
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import express from "express";
import router from "../src/features/api/router";
export function mountApi(app: express.Express) {
app.use("/services/v1", router);
}
+42
View File
@@ -0,0 +1,42 @@
import next from "next";
import express from "express";
import {mountApi} from "./api";
const port = Number(process.env.PORT) || 8887;
const dev = process.env.NODE_ENV !== "production";
async function start() {
const nextApp = next({
dev,
hostname: "0.0.0.0",
turbopack: dev,
port
});
const handle = nextApp.getRequestHandler();
await nextApp.prepare();
const server = express();
mountApi(server);
server.use((req, res, next) => {
if (req.path.startsWith('/services/v1')) return next();
handle(req, res).catch((err) => {
console.error('Next.js App Router error:', err);
res.status(500).send('Internal Server Error');
});
});
server.listen(port, "0.0.0.0", () => {
console.log(
`NEXT APP → http://localhost:${port}`
);
console.log(
`API → http://localhost:${port}/services/v1`
);
});
}
start();
@@ -75,7 +75,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
if (action === "download") { if (action === "download") {
console.log(inner.value)
const url = inner.value const url = inner.value
if (typeof url === "string") { if (typeof url === "string") {
window.open(url, "_self"); window.open(url, "_self");
@@ -57,7 +57,7 @@ export const downloadBackupAction = userAction.schema(
} }
}; };
console.log(input)
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId); const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
console.log(result); console.log(result);
@@ -106,8 +106,7 @@ export const ChannelPoliciesForm = ({
}); });
console.log(policiesToUpdate);
console.log(policiesToAdd);
const promises = kind === "notification" 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;
+133
View File
@@ -0,0 +1,133 @@
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) {
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);
}
+27
View File
@@ -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";
}
}
+129
View File
@@ -0,0 +1,129 @@
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,
});
}
});
export default router;
+3
View File
@@ -0,0 +1,3 @@
import { EventEmitter } from "events";
export const eventEmitter = new EventEmitter();
+1 -1
View File
@@ -11,7 +11,7 @@ import * as drizzleDb from "@/db";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import crypto, {createHash} from "crypto"; import {createHash} from "crypto";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import path from "path"; 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 {drive_v3, google} from "googleapis";
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types"; import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
import Drive = drive_v3.Drive; import Drive = drive_v3.Drive;
@@ -42,7 +9,6 @@ export async function getGoogleDriveClient(config: GoogleDriveConfig): Promise<D
const oauth2Client = new google.auth.OAuth2( const oauth2Client = new google.auth.OAuth2(
config.clientId, config.clientId,
config.clientSecret, config.clientSecret,
// config.redirectUri
baseUrl baseUrl
); );
@@ -24,18 +24,27 @@ export async function uploadGoogleDrive(
? await ensureFolderPath(client, folderPath, config.folderId) ? await ensureFolderPath(client, folderPath, config.folderId)
: config.folderId; : config.folderId;
const existing = await findFileByName(client, fileName, folderId); const existing = await findFileByName(client, fileName, folderId);
if (existing) return {success: false, provider: "google-drive", error: "File already exists"}; 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({ await client.files.create({
requestBody: {name: fileName, parents: [folderId]}, requestBody: {name: fileName, parents: [folderId]},
media: {body: Readable.from(input.data.file as Buffer)}, media: {body: fileStream},
fields: "id", fields: "id",
supportsAllDrives: true, supportsAllDrives: true,
}); });
if (input.data.url) { if (input.data.url) {
const url = await generateFileUrl(input); const url = await generateFileUrl(input);
if (!url) { if (!url) {
@@ -70,9 +79,11 @@ export async function getGoogleDrive(
const res = await client.files.get( const res = await client.files.get(
{fileId, alt: "media", supportsAllDrives: true}, {fileId, alt: "media", supportsAllDrives: true},
{responseType: "arraybuffer"} {responseType: "stream"}
); );
const stream = res.data as Readable;
if (input.data.signedUrl) { if (input.data.signedUrl) {
const url = await generateFileUrl(input); const url = await generateFileUrl(input);
@@ -88,7 +99,7 @@ export async function getGoogleDrive(
return { return {
success: true, success: true,
provider: "google-drive", provider: "google-drive",
file: Buffer.from(res.data as ArrayBuffer), file: stream,
url: url, url: url,
}; };
} }
@@ -96,7 +107,7 @@ export async function getGoogleDrive(
return { return {
success: true, success: true,
provider: "google-drive", 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 = { export type GoogleDriveConfig = {
clientId: string; clientId: string;
+1 -3
View File
@@ -1,7 +1,7 @@
import { import {
StorageProviderKind, StorageProviderKind,
StorageInput, StorageInput,
StorageResult, StorageMetaData, StorageResult,
} from '../types'; } from '../types';
import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local'; import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local';
@@ -39,8 +39,6 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
delete: deleteGoogleDrive, delete: deleteGoogleDrive,
ping: pingGoogleDrive, ping: pingGoogleDrive,
} }
// gcs: null as any,
// azure: null as any,
}; };
export async function dispatchViaProvider( export async function dispatchViaProvider(
+55 -41
View File
@@ -1,95 +1,109 @@
"use server" "use server"
import {mkdir, writeFile, unlink, readFile} from 'fs/promises'; import {mkdir, unlink} from 'fs/promises';
import path from 'path'; import path from 'path';
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../types'; import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../types';
import fs from "node:fs"; import fs from "node:fs";
import {getServerUrl} from "@/utils/get-server-url";
import {generateFileUrl} from "@/features/storages/helpers"; import {generateFileUrl} from "@/features/storages/helpers";
import {Readable} from "node:stream";
const BASE_DIR = "/private/uploads/"; const BASE_DIR = "/private/uploads/";
export async function uploadLocal( export async function uploadLocal(
config: { baseDir?: string }, config: { baseDir?: string },
input: { data: StorageUploadInput, metadata?: StorageMetaData } input: { data: StorageUploadInput; metadata?: StorageMetaData }
): Promise<StorageResult> { ): Promise<StorageResult> {
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);
const dir = path.dirname(fullPath); const dir = path.dirname(fullPath);
await mkdir(dir, {recursive: true}); await mkdir(dir, { recursive: true });
await writeFile(fullPath, input.data.file);
try {
if (input.data.url) { const file = input.data.file;
const url = await generateFileUrl(input); if (Buffer.isBuffer(file)) {
if (!url) { await fs.promises.writeFile(fullPath, input.data.file);
return { } else if (file instanceof Readable) {
success: false, await new Promise<void>((resolve, reject) => {
provider: "local", const writable = fs.createWriteStream(fullPath);
response: "Unable to get url file" 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, if (input.data.url) {
provider: 'local', const url = await generateFileUrl(input);
url: url 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( export async function getLocal(
config: { baseDir?: string }, config: { baseDir?: string },
input: { data: StorageGetInput, metadata: StorageMetaData } input: { data: StorageGetInput; metadata: StorageMetaData }
): Promise<StorageResult> { ): Promise<StorageResult> {
const base = config.baseDir || BASE_DIR; const base = config.baseDir || BASE_DIR;
const filePath = path.join(process.cwd(), base, input.data.path) const filePath = path.join(process.cwd(), base, input.data.path);
const fileName = path.basename(input.data.path);
const file = await readFile(filePath);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath); console.error("File not found at:", filePath);
return ({ return {
success: false, 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) { if (input.data.signedUrl) {
const url = await generateFileUrl(input); const url = await generateFileUrl(input);
if (!url) { if (!url) {
return { return {
success: false, success: false,
provider: "local", provider: "local",
response: "Unable to get url file" error: "Unable to generate signed URL",
}; };
} }
return { return {
success: true, success: true,
provider: "local", provider: "local",
file: file, file: fileStream,
url: url, url,
}; };
} }
return { return {
success: true, success: true,
provider: "local", provider: "local",
file: file, file: fileStream,
}; };
} }
export async function deleteLocal( export async function deleteLocal(
config: { baseDir?: string }, config: { baseDir?: string },
input: { data: StorageDeleteInput, metadata?: StorageMetaData } input: { data: StorageDeleteInput, metadata?: StorageMetaData }
+33 -39
View File
@@ -1,6 +1,6 @@
import * as Minio from "minio"; import * as Minio from "minio";
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types"; import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types";
import {generateFileUrl} from "@/features/storages/helpers"; import {Readable} from "node:stream";
type S3Config = { type S3Config = {
endPointUrl: string; endPointUrl: string;
@@ -32,72 +32,66 @@ async function ensureBucket(config: S3Config) {
export async function uploadS3( export async function uploadS3(
config: S3Config, config: S3Config,
input: { data: StorageUploadInput, metadata?: StorageMetaData } input: { data: StorageUploadInput, metadata?: StorageMetaData }
): Promise<StorageResult> { ): Promise<StorageResult> {
const client = await getS3Client(config); const client = await getS3Client(config);
await ensureBucket(config); await ensureBucket(config);
const key = `${BASE_DIR}${input.data.path}`; 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 { try {
await client.statObject(config.bucketName, key); const result = await client.putObject(config.bucketName, key, uploadStream, input.data.size);
return {success: false, provider: "s3", error: "File already exists"}; } catch (err: any) {
} catch { return {success: false, provider: "s3", error: err.message};
// continue if not found
} }
await client.putObject(config.bucketName, key, input.data.file as Buffer); return {success: true, provider: "s3"};
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',
};
} }
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}`; const key = `${BASE_DIR}${input.data.path}`;
try { try {
await client.statObject(config.bucketName, key); await client.statObject(config.bucketName, key);
} catch { } catch {
return {success: false, provider: "s3", error: "File not found"}; 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 fileStream = await client.getObject(config.bucketName, key);
const chunks: Buffer[] = [];
for await (const chunk of fileStream) chunks.push(chunk as Buffer); let presignedUrl: string | undefined;
const buffer = Buffer.concat(chunks); if (input.data.signedUrl) {
presignedUrl = await client.presignedGetObject(config.bucketName, key, input.data.expiresInSeconds ?? 60);
}
return { return {
success: true, success: true,
provider: "s3", provider: "s3",
file: buffer, file: fileStream as unknown as Buffer | Readable,
url: presignedUrl, 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 client = await getS3Client(config);
const key = `${BASE_DIR}${input.data.path}`; const key = `${BASE_DIR}${input.data.path}`;
+6 -3
View File
@@ -1,3 +1,5 @@
import {Readable} from "node:stream";
export type StorageProviderKind = export type StorageProviderKind =
| 'local' | 'local'
| 's3' | 's3'
@@ -20,9 +22,10 @@ export type StorageMetaData = {
export interface StorageUploadInput { export interface StorageUploadInput {
path: string; path: string;
file: Buffer | Uint8Array; file: Readable | Buffer | Uint8Array;
url?: boolean; url?: boolean;
contentType?: string; contentType?: string;
size?: number;
} }
export interface StorageGetInput { export interface StorageGetInput {
@@ -37,7 +40,7 @@ export interface StorageDeleteInput {
export type StorageInput = export type StorageInput =
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData } | { 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: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
| { action: 'ping'; }; | { action: 'ping'; };
@@ -45,7 +48,7 @@ export interface StorageResult {
success: boolean; success: boolean;
provider: StorageProviderKind | null; provider: StorageProviderKind | null;
url?: string; url?: string;
file?: Buffer; file?: Buffer | Readable;
error?: string; error?: string;
response?: any; response?: any;
} }
-1
View File
@@ -517,7 +517,6 @@ export const getActiveMember = async () => {
const member = await auth.api.getActiveMember({ const member = await auth.api.getActiveMember({
headers: await headers(), headers: await headers(),
}); });
console.log(member);
return member as MemberWithUser; return member as MemberWithUser;
} catch (e) { } catch (e) {
+1
View File
@@ -1,4 +1,5 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import {Server} from "./types"
export const createTransporter = (server: Server) => { export const createTransporter = (server: Server) => {
const portNumber = Number(server.port); const portNumber = Number(server.port);
+1
View File
@@ -3,6 +3,7 @@ import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {createTransporter} from "@/lib/email/helpers"; import {createTransporter} from "@/lib/email/helpers";
import {Payload} from "@/lib/email/types";
export const sendEmail = async (data: Payload) => { export const sendEmail = async (data: Payload) => {
const settings = await db const settings = await db
+2 -2
View File
@@ -1,14 +1,14 @@
"use server"; "use server";
type Payload = { export type Payload = {
to: string; to: string;
from?: string; from?: string;
subject: string; subject: string;
html: any; html: any;
}; };
type Server = { export type Server = {
host: string; host: string;
port: number; port: number;
user: string; 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 {retentionPolicy} from "@/db/schema/07_database";
import {isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eventEmitter} from "../../../../app/api/events/route"; import {eventEmitter} from "@/features/shared/event";
export const retentionCleanTask = async () => { export const retentionCleanTask = async () => {
+3 -3
View File
@@ -19,9 +19,9 @@ async function getS3Client() {
} }
const baseConfig = { const baseConfig = {
endPoint: settings.s3EndPointUrl ?? "", endPoint: settings?.s3EndPointUrl ?? "",
accessKey: settings.s3AccessKeyId ?? "", accessKey: settings?.s3AccessKeyId ?? "",
secretKey: settings.s3SecretAccessKey ?? "", secretKey: settings?.s3SecretAccessKey ?? "",
}; };
return new Minio.Client({ return new Minio.Client({