Merge branch 'feature'

This commit is contained in:
charlesgauthereau
2026-01-20 19:50:40 +01:00
27 changed files with 4809 additions and 141 deletions
+4
View File
@@ -16,6 +16,10 @@ SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
#SMTP_SECURE="true"
#Port 587 -> secure: false
#Port 465 -> secure: true
#By default : true
# Google # Google
AUTH_GOOGLE_ID= AUTH_GOOGLE_ID=
+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.1-rc.1 version: 1.2.1-rc.3
date-released: "2026-01-18" date-released: "2026-01-20"
+9 -1
View File
@@ -34,6 +34,9 @@ export async function POST(
const generatedId = formData.get("generatedId") as string | null; const generatedId = formData.get("generatedId") as string | null;
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"},
@@ -113,6 +116,7 @@ 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"},
@@ -127,7 +131,11 @@ export async function POST(
const fileName = `${uuid}${fileExtension}`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
await storeBackupFiles(backup, database, buffer, fileName)
console.log(extension)
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup"); await sendNotificationsBackupRestore(database, "success_backup");
+7 -1
View File
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db"; import {db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type"; import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action"; import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod"; import {ZodString} from "zod";
@@ -50,6 +50,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
{status: 500} {status: 500}
); );
} }
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`);
continue;
}
const [databaseCreated] = await dbClient const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database) .insert(drizzleDb.schemas.database)
.values({ .values({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.1-rc.1", "version": "1.2.1-rc.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -26,7 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action"; } from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {sendEmail} from "@/lib/email/email-helper"; import {sendEmail} from "@/lib/email";
import {render} from "@react-email/components"; import {render} from "@react-email/components";
import EmailSettingsTest from "@/components/emails/email-settings-test"; import EmailSettingsTest from "@/components/emails/email-settings-test";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
@@ -18,7 +18,7 @@ import {userAction} from "@/lib/safe-actions/actions";
import { import {
addMemberOrganizationAction addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action"; } from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {sendEmail} from "@/lib/email/email-helper"; import {sendEmail} from "@/lib/email";
import EmailCreateUser from "@/components/emails/email-create-user"; import EmailCreateUser from "@/components/emails/email-create-user";
import {SignUpUser} from "@/types/auth"; import {SignUpUser} from "@/types/auth";
import {createUserDb} from "@/db/services/user"; import {createUserDb} from "@/db/services/user";
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mongodb';
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "smtp_secure" boolean;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -211,6 +211,20 @@
"when": 1768743644834, "when": 1768743644834,
"tag": "0029_lowly_white_tiger", "tag": "0029_lowly_white_tiger",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1768763883339,
"tag": "0030_dizzy_morlocks",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1768931603846,
"tag": "0031_chemical_edwin_jarvis",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -1,4 +1,4 @@
import {pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core"; import {boolean, pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
import {typeStorageEnum} from "./types"; import {typeStorageEnum} from "./types";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
@@ -19,6 +19,7 @@ export const setting = pgTable("settings", {
smtpHost: varchar("smtp_host", {length: 255}), smtpHost: varchar("smtp_host", {length: 255}),
smtpPort: varchar("smtp_port", {length: 255}), smtpPort: varchar("smtp_port", {length: 255}),
smtpUser: varchar("smtp_user", {length: 255}), smtpUser: varchar("smtp_user", {length: 255}),
smtpSecure: boolean("smtp_secure"),
defaultStorageChannelId: uuid('default_storage_channel_id') defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}), .references(() => storageChannel.id, {onDelete: "set null"}),
...timestamps ...timestamps
+1 -1
View File
@@ -2,7 +2,7 @@ import { pgEnum } from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql"]); export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]); export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]); export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+2
View File
@@ -22,6 +22,7 @@ export const env = createEnv({
SMTP_HOST: z.string().optional(), SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(), SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_SECURE: z.coerce.boolean().default(true),
AUTH_GOOGLE_ID: z.string().optional(), AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(), AUTH_GOOGLE_SECRET: z.string().optional(),
@@ -60,6 +61,7 @@ export const env = createEnv({
SMTP_HOST: process.env.SMTP_HOST, SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT, SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER, SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID, AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET, AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
+1 -1
View File
@@ -9,7 +9,7 @@ import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} fro
import {headers} from "next/headers"; import {headers} from "next/headers";
import {count, eq} from "drizzle-orm"; import {count, eq} from "drizzle-orm";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization"; import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {sendEmail} from "@/lib/email/email-helper"; import {sendEmail} from "@/lib/email";
import {render} from "@react-email/render"; import {render} from "@react-email/render";
import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config"; import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
-104
View File
@@ -1,104 +0,0 @@
"use server";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import nodemailer from "nodemailer";
import * as drizzleDb from "@/db";
type Payload = {
to: string;
from?: string;
subject: string;
html: any;
};
type Server = {
host: string;
port: number;
user: string;
pass: string;
from: string;
};
type EmailCustomProps = {
data: Payload;
server: Server;
};
type EmailMassProps = {
data: Payload;
servers: Server[];
};
export const sendEmail = async (data: Payload) => {
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",")
.map(email => email.trim());
const transporter = nodemailer.createTransport({
pool: true,
host: settings.smtpHost ?? "",
port: parseInt(settings.smtpPort ?? "587"),
secure: true,
auth: {
user: settings.smtpUser ?? "",
pass: settings.smtpPassword ?? "",
},
});
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
};
export const sendCustomEmail = async (data: EmailCustomProps) => {
const transporter = nodemailer.createTransport({
secure: true,
replyTo: data.server.user,
host: data.server.host,
port: data.server.port,
auth: {
user: data.server.user,
pass: data.server.pass,
},
});
return await transporter.sendMail({
...data.data,
});
};
export const sendMassEmail = async (data: EmailMassProps) => {
for (const server of data.servers) {
const transporter = nodemailer.createTransport({
secure: true,
host: server.host,
port: server.port,
auth: {
user: server.user,
pass: server.pass,
},
});
return await transporter.sendMail({
...data.data,
});
}
};
+15
View File
@@ -0,0 +1,15 @@
import nodemailer from "nodemailer";
export const createTransporter = (server: Server) => {
const portNumber = Number(server.port);
return nodemailer.createTransport({
pool: true,
host: server.host,
port: portNumber,
secure: server.secure ?? portNumber === 465,
auth: {
user: server.user,
pass: server.pass,
},
});
};
+41
View File
@@ -0,0 +1,41 @@
"use server"
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {createTransporter} from "@/lib/email/helpers";
export const sendEmail = async (data: Payload) => {
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",")
.map(email => email.trim());
const transporter = createTransporter({
host: settings.smtpHost,
port: Number(settings.smtpPort),
user: settings.smtpUser,
pass: settings.smtpPassword,
from: settings.smtpFrom,
secure: settings.smtpSecure ?? false,
});
await transporter.verify();
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
};
+19
View File
@@ -0,0 +1,19 @@
"use server";
type Payload = {
to: string;
from?: string;
subject: string;
html: any;
};
type Server = {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
};
+3 -3
View File
@@ -18,9 +18,9 @@ export const retentionCleanTask = async () => {
}, },
}, },
}); });
console.log(`Retention databases number: ${databases.length}`);
for (const db of databases) { for (const db of databases) {
if (!db.retentionPolicy) continue; // no policy = skip if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy); await enforceRetention(db.id, db.retentionPolicy);
} }
} catch (e: any) { } catch (e: any) {
@@ -33,7 +33,7 @@ export async function enforceRetention(
databaseId: string, databaseId: string,
policy: typeof retentionPolicy.$inferSelect policy: typeof retentionPolicy.$inferSelect
) { ) {
console.log(`Retention started for ${databaseId}`);
switch (policy.type) { switch (policy.type) {
case "count": case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7); await enforceRetentionCount(databaseId, policy.count ?? 7);
+12 -2
View File
@@ -2,8 +2,10 @@ import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, desc, eq, isNull} from "drizzle-orm"; import {and, desc, eq, isNull} from "drizzle-orm";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {toast} from "sonner";
export async function enforceRetentionCount(databaseId: string, count: number) { export async function enforceRetentionCount(databaseId: string, count: number) {
console.log(`[Retention Count] - ${databaseId} : started`);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -16,12 +18,20 @@ export async function enforceRetentionCount(databaseId: string, count: number) {
} }
}); });
const toDelete = backups.slice(count); // keep first `count`, delete rest const toDelete = backups.slice(count);
console.log(`[Retention Count] - ${databaseId} : ${toDelete.length} backups to delete`);
for (const b of toDelete) { for (const b of toDelete) {
await deleteBackupCronAction({ const result = await deleteBackupCronAction({
backupId: b.id, backupId: b.id,
databaseId: b.databaseId, databaseId: b.databaseId,
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
+9 -1
View File
@@ -4,6 +4,7 @@ import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionDays(databaseId: string, days: number) { export async function enforceRetentionDays(databaseId: string, days: number) {
console.log(`Enforce Retention Days starting for ${databaseId}`);
const cutoff = new Date(Date.now() - days * 86400000); const cutoff = new Date(Date.now() - days * 86400000);
const expiredBackups = await db.query.backup.findMany({ const expiredBackups = await db.query.backup.findMany({
@@ -22,11 +23,18 @@ export async function enforceRetentionDays(databaseId: string, days: number) {
}); });
for (const backup of expiredBackups) { for (const backup of expiredBackups) {
await deleteBackupCronAction({
const result = await deleteBackupCronAction({
backupId: backup.id, backupId: backup.id,
databaseId: backup.databaseId, databaseId: backup.databaseId,
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
} else {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
+10 -1
View File
@@ -10,6 +10,8 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
monthly: number; monthly: number;
yearly: number; yearly: number;
}) { }) {
console.log(`Enforce Retention GFS starting for ${databaseId}`);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -60,10 +62,17 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
for (const b of backups) { for (const b of backups) {
if (!toKeep.has(b.id)) { if (!toKeep.has(b.id)) {
await deleteBackupCronAction({ const result = await deleteBackupCronAction({
backupId: b.id, backupId: b.id,
databaseId: b.databaseId, databaseId: b.databaseId,
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
} }
+3 -13
View File
@@ -22,9 +22,10 @@ export const deleteBackupCronAction = action
try { try {
const backup = await db.query.backup.findFirst({ const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId)) where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))
}); });
if (!backup) { if (!backup) {
return { return {
success: false, success: false,
@@ -42,17 +43,6 @@ export const deleteBackupCronAction = action
}); });
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: parsedInput.backupId},
},
};
}
for (const backupStorage of backupStorages) { for (const backupStorage of backupStorages) {
await db await db
@@ -81,7 +71,7 @@ export const deleteBackupCronAction = action
.set(withUpdatedAt({ .set(withUpdatedAt({
deletedAt: new Date(), deletedAt: new Date(),
})) }))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId))) .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
return { return {
success: true, success: true,
+1 -2
View File
@@ -4,8 +4,6 @@ import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {retentionJob} from "@/lib/tasks"; import {retentionJob} from "@/lib/tasks";
import {generateRSAKeys} from "@/utils/rsa-keys"; import {generateRSAKeys} from "@/utils/rsa-keys";
import {Provider} from "react";
import type {ProviderKind} from "@/features/notifications/types";
import {StorageProviderKind} from "@/features/storages/types"; import {StorageProviderKind} from "@/features/storages/types";
@@ -34,6 +32,7 @@ async function createSettingsIfNotExist() {
smtpHost: env.SMTP_HOST ?? null, smtpHost: env.SMTP_HOST ?? null,
smtpPort: env.SMTP_PORT ?? null, smtpPort: env.SMTP_PORT ?? null,
smtpUser: env.SMTP_USER ?? null, smtpUser: env.SMTP_USER ?? null,
smtpSecure: env.SMTP_SECURE,
}; };
const [existing] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); const [existing] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);