Compare commits

...
47 Commits
Author SHA1 Message Date
charlesgauthereau b9deb3cdf0 chore(release): 1.3.0-rc.1 2026-02-23 21:56:38 +01:00
Charles GTEandGitHub 5dbf293e1e Merge pull request #94 from Portabase/feat/sqlite
feat: add sqlite support
2026-02-23 21:50:37 +01:00
charlesgauthereau 7232f17cb7 feat: add sqlite support, first working version. 2026-02-22 21:46:54 +01:00
charlesgauthereau 5b1c3d0606 chore(release): 1.2.9 2026-02-19 21:13:33 +01:00
Charles GTEandGitHub 8e6ca64fa2 Merge pull request #93 from Portabase/fix/s3-port
fix: type error when user add port for s3 storages.
2026-02-19 21:12:09 +01:00
charlesgauthereau 9116b4d126 fix: type error when user add port for s3 storages. 2026-02-19 21:09:56 +01:00
charlesgauthereau 4ce38d9857 chore(release): 1.2.8 2026-02-19 13:49:16 +01:00
charlesgauthereau 8811c48384 Merge branch 'dev'
# Conflicts:
#	README.md
2026-02-19 13:48:20 +01:00
Killian LarcherandGitHub 078d0d1ee2 Update demo video link and dashboard image 2026-02-19 11:51:08 +01:00
Killian LarcherandGitHub 56d755afa6 Change demo video link and update image
Updated demo link and image in README.
2026-02-19 11:35:41 +01:00
Killian LarcherandGitHub 9e3abb366f Change dashboard image to video link
Updated the dashboard image link to a video.
2026-02-19 11:34:10 +01:00
charlesgauthereau 4ebac1c46f fix: ts-ignore 2026-02-18 19:09:20 +01:00
Charles GTEandGitHub 1476df7a1f Merge pull request #91 from Portabase/feat/manage-master-key
feat(master_key): download master key file
2026-02-18 19:08:17 +01:00
charlesgauthereau 833872603a feat(master_key): download master key file 2026-02-18 19:07:14 +01:00
Charles GTEandGitHub ebe5c63a7b Merge pull request #90 from Portabase/fix/add-backup-cleanup
fix/add-backup-cleanup
2026-02-18 17:49:27 +01:00
charlesgauthereau afd98ae652 chore(release): 1.2.8-rc.1 2026-02-17 08:48:52 +01:00
charlesgauthereau 8f905c8128 fix: stuck backups with ongoing status but deleted, for legacy. 2026-02-17 08:47:57 +01:00
charlesgauthereau f43f30c9b9 chore(release): 1.2.7 2026-02-16 16:40:01 +01:00
charlesgauthereau 6b1bea9b17 Merge remote-tracking branch 'origin/main' 2026-02-16 16:39:31 +01:00
charlesgauthereau cdaed9766e fix: twice email sent by better auth 2026-02-16 16:39:19 +01:00
Charles GTEandGitHub d31a9ab2d2 Merge pull request #87 from Portabase/dev
1.2.7
2026-02-16 16:38:36 +01:00
Killian LarcherandGitHub 07d49749fb Merge pull request #86 from Portabase/fix/test-button-notif
Fix/test button notif
2026-02-16 16:35:44 +01:00
killianlarcher a1f1da8a07 chore(release): 1.2.7-rc.3 2026-02-16 16:17:30 +01:00
killian-larcher 1b791fd287 fix: re-enable Test Button when editing notification channel. 2026-02-16 16:14:41 +01:00
charlesgauthereau 2a4af58a51 Merge remote-tracking branch 'origin/dev' into dev 2026-02-16 15:37:37 +01:00
charlesgauthereau b12f8ca044 chore(release): 1.2.7-rc.2 2026-02-16 15:37:06 +01:00
Charles GTEandGitHub 519b3abd1d Merge pull request #84 from Portabase/fix/twice-email-session
fix: twice email sent by better auth
2026-02-16 15:36:06 +01:00
charlesgauthereau e08a5bd6b6 fix: twice email sent by better auth 2026-02-16 15:34:31 +01:00
Killian LarcherandGitHub f6c597b42d Merge pull request #83 from Portabase/fix/optional-csp
Conditionally enable UPGRADE_INSECURE_REQUESTS based on PROJECT_URL scheme
2026-02-16 15:23:49 +01:00
killianlarcher f3b9032174 chore(release): 1.2.7-rc.1 2026-02-16 13:23:36 +01:00
killianlarcher 44b4ee2953 fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:21:09 +01:00
killianlarcher b77f79f42d fix(csp): Conditionally sets UPGRADE_INSECURE_REQUESTS based on the PROJECT_URL scheme. 2026-02-16 13:18:58 +01:00
Charles GTEandGitHub 8017bf7c8a Merge pull request #80 from Portabase/dev
fix: README.md
2026-02-15 11:50:54 +01:00
charlesgauthereau 85f4cda752 fix: README.md 2026-02-15 11:49:01 +01:00
charlesgauthereau 1fd2f553ed chore(release): 1.2.6 2026-02-15 10:59:43 +01:00
Charles GTEandGitHub d68170eda8 Merge pull request #79 from Portabase/dev
Dev
2026-02-15 10:59:09 +01:00
Charles GTEandGitHub 51f27226e7 Merge branch 'main' into dev 2026-02-15 10:48:37 +01:00
charlesgauthereau d54c366323 chore(release): 1.2.6-rc.2 2026-02-15 10:21:24 +01:00
Charles GTEandGitHub 0e7cb9fbab Merge pull request #78 from Portabase/feat/internal-database
feat/internal-database
2026-02-15 10:20:25 +01:00
charlesgauthereau 63e83032f1 fix: working on adding internal postgres database. 2026-02-15 10:19:28 +01:00
charlesgauthereau b4da6fdd9f fix: working on adding internal postgres database. 2026-02-14 22:23:04 +01:00
charlesgauthereau 12535a59e8 fix: docker-compose.prod.yml 2026-02-14 19:22:24 +01:00
Charles GTEandGitHub b345eda7aa Update README.md 2026-02-14 09:32:02 +01:00
Charles GTEandGitHub 660c1dcadf Merge pull request #76 from Portabase/fix/backup-delete
fix: bug ongoing backup deletion
2026-02-13 22:41:48 +01:00
charlesgauthereau be66856034 chore(release): 1.2.6-rc.1 2026-02-13 22:41:29 +01:00
charlesgauthereau 80e3027107 fix: bug ongoing backup deletion 2026-02-13 22:40:44 +01:00
charlesgauthereau 362ca53efb fix: console.log 2026-02-13 15:58:53 +01:00
50 changed files with 2667 additions and 1168 deletions
+1 -3
View File
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
[allowlist]
# Global allowlist patterns (wont be flagged)
regexes = [
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
]
regexes = []
paths = [
"src/utils/init.ts"
]
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.2.5
date-released: "2026-02-13"
version: 1.3.0-rc.1
date-released: "2026-02-23"
+5 -7
View File
@@ -7,7 +7,7 @@
<h3 align="center">Portabase</h3>
<p align="center" style="margin-top: 20px; font-style: italic;">
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
</p>
@@ -23,7 +23,6 @@
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
[![NextJS][NextJS]][NextJS-url]
[![BetterAuth][BetterAuth]][BetterAuth-url]
[![Drizzle][Drizzle]][Drizzle-url]
@@ -34,14 +33,14 @@
<strong>
<a href="https://portabase.io">Website</a> •
<a href="https://portabase.io/docs">Documentation</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
<a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong>
</p>
![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6)
![portabase-dashboard](https://github.com/user-attachments/assets/8f2c69d6-f1f9-4b80-b51c-01f6f13b9b62)
</div>
@@ -51,14 +50,13 @@
You have 4 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm (soon)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.**
## Contributors
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
@@ -1,30 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System email</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsEmailSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -1,29 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {db} from "@/db";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System storage</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsStorageSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -35,12 +35,10 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader>
<PageTitle>Notification channels</PageTitle>
<PageActions>
{/*<NotifierAddEditModal adminView={false}/>*/}
<ChannelAddEditModal kind="notification" adminView={false}/>
</PageActions>
</PageHeader>
<PageContent>
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
<ChannelsSection kind="notification" organizations={organizations}
channels={notificationChannels}/>
</PageContent>
+3 -1
View File
@@ -1,6 +1,7 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {env} from "@/env.mjs";
export async function POST(request: Request) {
@@ -29,7 +30,8 @@ export async function POST(request: Request) {
}
const uploadDir = path.join(process.cwd(), "/private/uploads/");
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
+8 -8
View File
@@ -1,19 +1,18 @@
name: portabase-prod
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
image: portabase/portabase:1.2.5-rc.9
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.2.7-rc.1
ports:
- '8887:80'
environment:
TZ: "Europe/Paris"
env_file:
- .env
volumes:
- portabase-data:/data
depends_on:
db:
condition: service_healthy
@@ -36,4 +35,5 @@ services:
retries: 5
volumes:
portabase-data:
postgres-data:
+23 -3
View File
@@ -18,6 +18,21 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
RUN apt-get update && apt-get install -y \
postgresql-18 \
postgresql-client-18 \
postgresql-contrib-18 \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
ARG TUSD_VERSION=2.8.0
@@ -76,6 +91,9 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=80
ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -94,14 +112,16 @@ RUN chmod +x /usr/local/bin/tusd
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
#RUN mkdir -p .next /app/private/uploads \
# && chown -R nextjs:nodejs .next /app/private /app/public
RUN mkdir -p .next /data/private/uploads \
&& chown -R nextjs:nodejs .next /data/private /app/public
RUN mkdir -p .next /app/private/uploads \
&& chown -R nextjs:nodejs .next /app/private /app/public
USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
RUN chmod +x /app/app-prod-entrypoint.sh
EXPOSE 80
#USER nextjs
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
+65 -2
View File
@@ -7,9 +7,72 @@ else
echo "[WARN] No TZ provided, using default container timezone"
fi
mkdir -p /app/private/uploads/tmp
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
if [ -z "$POSTGRES_BIN" ]; then
echo "PostgreSQL binaries not found"
exit 1
fi
export PATH="$POSTGRES_BIN:$PATH"
if [ -z "$DATABASE_URL" ]; then
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
mkdir -p "$PGDATA"
chown -R postgres:postgres "$PGDATA"
if [ ! -f "$PGDATA/PG_VERSION" ]; then
echo "[INFO] Initializing database cluster..."
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
echo "[ERROR] initdb failed"
exit 1
fi
fi
if ! su postgres -c "pg_ctl -D '$PGDATA' \
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
echo "[ERROR] PostgreSQL failed to start"
exit 1
fi
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
sleep 1
done
echo "[INFO] PostgreSQL server is up and accepting connections"
DB_USER="${POSTGRES_USER:-portabase_user}"
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
DB_NAME="${POSTGRES_DB:-portabase_db}"
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
if [ "$USER_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating user"
exit 1
fi
fi
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
if [ "$DB_EXISTS" != "1" ]; then
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
echo "[ERROR] Failed creating database"
exit 1
fi
fi
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
echo "[SUCCESS] Internal PostgreSQL started successfully"
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
fi
mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /app/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 &
echo "▶ Starting Next.js server..."
PORT=3000 node server.js &
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.2.5",
"version": "1.3.0-rc.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -127,5 +127,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
+1 -3
View File
@@ -1,5 +1,3 @@
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
isActive: boolean;
@@ -64,7 +62,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
FORM_ACTION: ["'self'"],
FRAME_ANCESTORS: ["'none'"],
BLOCK_ALL_MIXED_CONTENT: false,
UPGRADE_INSECURE_REQUESTS: true,
UPGRADE_INSECURE_REQUESTS: process.env.PROJECT_URL?.startsWith("https://") || false,
},
PERMISSIONS_POLICY: {
CAMERA: ["()"],
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ set -e
if [ -z "$1" ]; then
echo "Usage: ./release <version>"
echo "Example: ./release v1.0.0"
echo "Example: ./release 1.0.0"
exit 1
fi
+2
View File
@@ -208,12 +208,14 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
schema: Z;
};
const useZodForm = <Z extends ZodSchema>({
schema,
...formProps
}: UseZodFormProps<Z>) =>
useForm({
...formProps,
// @ts-ignore
resolver: zodResolver(schema),
});
@@ -224,7 +224,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<div className="flex justify-between mt-4">
<div>
{kind == "storage" && (
{(!isCreate || kind == "storage") && (
<ChannelTestButton
kind={kind}
organizationId={organization?.id}
@@ -34,7 +34,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
message: `We are testing channel ${channel.name}`,
level: 'info',
};
const result = await dispatchNotification(payload, undefined, channel, organizationId);
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
if (result.success) {
toast.success(result.message);
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
toast.success("Successfully connected to storage channel");
} else {
console.error(result);
toast.error("An error occurred while testing the storage channel, check your configuration");
const responseText = result.response ? `(${result.response})` : "";
toast.error(`An error occurred while testing the storage channel, check your configuration ${responseText}`);
}
} else {
toast.error("Not yet supported");
@@ -4,7 +4,15 @@ import {Info} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
@@ -18,6 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {toast} from "sonner";
import {Switch} from "@/components/ui/switch";
import {downloadMasterKeyAction} from "@/features/keys/keys.action";
export type SettingsStorageSectionProps = {
settings: Setting;
@@ -50,6 +59,34 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
}
});
const handleDownload = async () => {
const result = await downloadMasterKeyAction();
if (!result?.success) {
toast.error(result?.message ?? "Download failed");
return;
}
const byteCharacters = atob(result.data as string);
const byteNumbers = new Array(byteCharacters.length)
.fill(null)
.map((_, i) => byteCharacters.charCodeAt(i));
const blob = new Blob([new Uint8Array(byteNumbers)], {
type: "application/octet-stream",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "master_key.bin";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full">
<Alert className="mt-3">
@@ -59,7 +96,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
The default storage channel will be used by default to store your backups if no storage policy is
configured at the database level. </AlertDescription>
</Alert>
<div className="flex flex-col h-full py-4 ">
<div className="flex flex-col h-full py-4 gap-3">
<Form
className="space-y-4"
form={form}
@@ -96,8 +133,6 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</FormItem>
)}
/>
<FormField
control={form.control}
name="encryption"
@@ -120,6 +155,18 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
Confirm
</ButtonWithLoading>
</Form>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium ">
Download instance <button
type="button"
onClick={handleDownload}
className="text-sm hover:text-muted-foreground underline underline-offset-4 transition-colors w-fit"
>
Master Key
</button>
</span>
</div>
</div>
</div>
);
@@ -8,7 +8,7 @@ import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {z} from "zod";
import {storeBackupFiles} from "@/features/storages/helpers";
import {getFileExtension} from "@/features/api/upload/helpers/file";
import {getFileExtension} from "@/utils/common";
export const uploadBackupAction = userAction
@@ -46,18 +46,6 @@ export const uploadBackupAction = userAction
const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(arrayBuffer);
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
//
// if (!settings) {
// return {
// success: false,
// actionError: {
// message: "Settings not set",
// status: 500,
// cause: "Unknown error",
// },
// };
// }
const [backup] = await db
.insert(drizzleDb.schemas.backup)
@@ -68,30 +56,8 @@ export const uploadBackupAction = userAction
})
.returning();
await storeBackupFiles(backup, database, buffer, fileName)
// let success: boolean, message: string, filePath: string;
//
// const result =
// settings.storage === "local"
// ? await uploadLocalPrivate(fileName, buffer)
// : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
//
// ({success, message, filePath} = result);
//
// if (!success) {
// return {
// success: false,
// actionError: {
// message: "An error has occurred while uploading file",
// status: 500,
// cause: "Unknown error",
// },
// };
// }
return {
success: true,
value: backup,
@@ -12,7 +12,7 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
type DatabaseBackupListProps = {
@@ -69,19 +69,12 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
if (backup.deletedAt == null) {
if (backup.deletedAt == null || backup.status == "ongoing") {
const backupDeleted = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
// const backupDeleted = await deleteBackupAction({
// backupId: backup.id,
// databaseId: backup.databaseId,
// status: backup.status,
// file: backup.file ?? "",
// projectSlug: props.database?.project?.slug!
// });
return {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
@@ -136,15 +129,9 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<ButtonWithConfirm
<ButtonWithConfirm
onConfirm={() => {
const backupsToDelete = rows.map(row => row
).filter(backup => backup.deletedAt == null)
if (backupsToDelete.length === 0) {
toast.error("No available backup selected for deletion.");
return;
}
mutationDeleteBackups.mutate(backupsToDelete);
mutationDeleteBackups.mutate(rows);
setIsActionsOpen(false);
}}
onCancel={() => setIsActionsOpen(false)}
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'sqlite';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -239,6 +239,13 @@
"when": 1770923665015,
"tag": "0033_handy_valeria_richards",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1771790675240,
"tag": "0034_lush_speed",
"breakpoints": true
}
]
}
-1
View File
@@ -9,7 +9,6 @@ import {timestamps} from "@/db/schema/00_common";
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
import {storageChannel} from "@/db/schema/12_storage-channel";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb", "sqlite"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+6 -16
View File
@@ -1,6 +1,7 @@
import {createEnv} from "@t3-oss/env-nextjs";
import {z} from "zod";
import packageJson from "../package.json" with {type: "json"};
import path from "path";
const {version} = packageJson;
@@ -14,8 +15,8 @@ export const env = createEnv({
PROJECT_NAME: z.string().optional(),
PROJECT_DESCRIPTION: z.string().optional(),
PROJECT_URL: z.string().optional(),
PROJECT_SECRET: z.string().optional(),
PROJECT_URL: z.string().regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
@@ -28,19 +29,14 @@ export const env = createEnv({
AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GOOGLE_METHOD: z.boolean().default(false),
S3_ENDPOINT: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
STORAGE_TYPE: z.enum(["local", "s3"]).optional(),
RETENTION_CRON: z
.string()
.default(process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *"),
PRIVATE_PATH: z.string(),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -66,16 +62,10 @@ export const env = createEnv({
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
S3_PORT: process.env.S3_PORT,
S3_USE_SSL: process.env.S3_USE_SSL,
STORAGE_TYPE: process.env.STORAGE_TYPE,
RETENTION_CRON: process.env.RETENTION_CRON,
PRIVATE_PATH: process.env.PRIVATE_PATH || path.join(process.cwd(), 'private')
},
});
-6
View File
@@ -1,6 +0,0 @@
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
@@ -1,10 +0,0 @@
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;
-134
View File
@@ -1,134 +0,0 @@
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);
}
-27
View File
@@ -1,27 +0,0 @@
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";
}
}
-201
View File
@@ -1,201 +0,0 @@
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;
+12 -2
View File
@@ -8,7 +8,7 @@ import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {MemberWithUser} from "@/db/schema/03_organization";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes, isImportedFilename} from "@/utils/text";
import {formatBytes} from "@/utils/text";
import {DatabaseActionsCell} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-cell";
import { Badge as BadgeC } from "@/components/ui/badge";
@@ -22,7 +22,17 @@ export function backupColumns(
{
id: "availability",
cell: ({row}) => {
const colorStatus = row.original.deletedAt != null ? "bg-red-400 border-red-600" : "bg-green-400 border-green-600";
const statusColors: Record<string, string> = {
waiting: "bg-gray-400 border-gray-600",
ongoing: "bg-orange-400 border-orange-600",
success: "bg-green-400 border-green-600",
};
const colorStatus =
row.original.deletedAt != null
? "bg-red-400 border-red-600"
: statusColors[row.original.status] ?? "bg-gray-400 border-gray-600";
return (
<TooltipProvider>
<Tooltip>
@@ -7,12 +7,6 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {Backup, Restoration} from "@/db/schema/07_database";
import {
deleteFileS3Private,
deleteLocalPrivate,
} from "@/features/upload/private/upload.action";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
export const deleteRestoreAction = userAction
.schema(
@@ -48,84 +42,6 @@ export const deleteRestoreAction = userAction
}
});
export const deleteBackupAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
projectSlug: z.string(),
status: z.enum(["ongoing", "failed", "success", "waiting"]),
file: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try {
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return {
success: false,
actionError: {
message: "No settings found.",
status: 404,
cause: "No settings found.",
messageParams: {message: "Error deleting the backup"},
},
};
}
await db
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: parsedInput.status == "ongoing" ? "failed" : parsedInput.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
let success: boolean, message: string;
if (parsedInput.file) {
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
({success, message} = result);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
}
}
return {
success: true,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error deleting the backup"},
},
};
}
});
export const rerunRestorationAction = userAction
.schema(
z.object({
@@ -175,46 +91,3 @@ export const rerunRestorationAction = userAction
};
}
});
export const createRestorationAction = userAction
.schema(
z.object({
backupId: z.string(),
databaseId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const restorationData = await db
.insert(drizzleDb.schemas.restoration)
.values({
databaseId: parsedInput.databaseId,
backupId: parsedInput.backupId,
status: "waiting",
})
.returning()
.execute();
const createdRestoration = restorationData[0];
return {
success: true,
value: createdRestoration,
actionSuccess: {
message: "Restoration has been successfully created.",
messageParams: {restorationId: createdRestoration.id},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to create restoration.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {message: "Error creating the restoration"},
},
};
}
});
+27 -4
View File
@@ -1,12 +1,16 @@
"use server"
import fs from "node:fs";
import {env} from "@/env.mjs";
import path from "path";
/**
* Get Public server key content
*/
export function getPublicServerKeyContent() {
export async function getPublicServerKeyContent() {
try {
return fs.readFileSync("private/keys/server_public.pem", "utf8");
const keyPath = path.join(env.PRIVATE_PATH, '/keys/server_public.pem')
return fs.readFileSync(keyPath, "utf8");
} catch (error: any) {
console.error("Error :", error);
return {
@@ -20,9 +24,10 @@ export function getPublicServerKeyContent() {
/**
* Get Master server key
*/
export function getMasterServerKeyContent() {
export async function getMasterServerKeyContent() {
try {
return fs.readFileSync("private/keys/master_key.bin");
const keyPath = path.join(env.PRIVATE_PATH, '/keys/master_key.bin')
return fs.readFileSync(keyPath);
} catch (error: any) {
console.error("Error :", error);
return {
@@ -31,3 +36,21 @@ export function getMasterServerKeyContent() {
};
}
}
export async function downloadMasterKeyAction() {
try {
const keyPath = path.join(env.PRIVATE_PATH, "keys/master_key.bin");
const fileBuffer = fs.readFileSync(keyPath);
return {
success: true,
data: fileBuffer.toString("base64"),
};
} catch (error) {
return {
success: false,
message: "Unable to download master key",
};
}
}
@@ -16,6 +16,7 @@ import {Organization} from "@/db/schema/03_organization";
import {ProjectWith} from "@/db/schema/06_project";
import {GearIcon} from "@radix-ui/react-icons";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {useRouter} from "next/navigation";
type ProjectDialogProps = {
databases: DatabaseWith[];
@@ -33,6 +34,7 @@ export const ProjectDialog = ({
isEmpty = false
}: ProjectDialogProps) => {
const [open, setOpen] = useState(false);
const router = useRouter();
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -60,7 +62,10 @@ export const ProjectDialog = ({
<DialogTitle>{isEdit ? `Edit ${project?.name}` : "Create new project"}</DialogTitle>
</DialogHeader>
<ProjectForm
onSuccess={() => setOpen(false)}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
databases={databases}
organization={organization}
defaultValues={project ? {
@@ -22,11 +22,6 @@ export type projectFormProps = {
};
export const ProjectForm = (props: projectFormProps) => {
const router = useRouter();
const queryClient = useQueryClient();
+7 -2
View File
@@ -74,7 +74,10 @@ export async function dispatchStorage(
};
}
if (channelData) channel = {...channelData, config: channelData.config as Json};
if (channelData) {
// @ts-ignore
channel = {...channelData, config: channelData.config as Json};
}
if (!channel) {
return {
@@ -94,12 +97,14 @@ export async function dispatchStorage(
}
return await dispatchViaProvider(
const dispatchResult = await dispatchViaProvider(
channel.provider as StorageProviderKind,
channel.config,
input,
);
console.log(dispatchResult);
return dispatchResult;
} catch (err: any) {
return {
+14 -9
View File
@@ -5,15 +5,17 @@ import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, Sto
import fs from "node:fs";
import {generateFileUrl} from "@/features/storages/helpers";
import {Readable} from "node:stream";
import {env} from "@/env.mjs";
const BASE_DIR = "/private/uploads/";
const BASE_DIR = path.join(env.PRIVATE_PATH, '/uploads')
export async function uploadLocal(
config: { baseDir?: string },
input: { data: StorageUploadInput; metadata?: StorageMetaData }
): Promise<StorageResult> {
const base = config.baseDir || BASE_DIR;
const fullPath = path.join(process.cwd(), base, input.data.path);
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const fullPath = path.join(base, input.data.path);
const dir = path.dirname(fullPath);
await mkdir(dir, { recursive: true });
@@ -52,8 +54,9 @@ export async function getLocal(
config: { baseDir?: string },
input: { data: StorageGetInput; metadata: StorageMetaData }
): Promise<StorageResult> {
const base = config.baseDir || BASE_DIR;
const filePath = path.join(process.cwd(), base, input.data.path);
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const filePath = path.join(base, input.data.path);
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
@@ -108,8 +111,10 @@ export async function deleteLocal(
config: { baseDir?: string },
input: { data: StorageDeleteInput, metadata?: StorageMetaData }
): Promise<StorageResult> {
const base = config.baseDir || BASE_DIR;
const fullPath = path.join(process.cwd(), base, input.data.path);
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const fullPath = path.join(base, input.data.path);
await unlink(fullPath);
return {
success: true,
@@ -121,8 +126,8 @@ export async function pingLocal(
config: { baseDir?: string }
): Promise<StorageResult> {
const base = config.baseDir || BASE_DIR;
const fullPath = path.join(process.cwd(), base, "ping.txt");
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
const fullPath = path.join(base, "ping.txt");
await fs.promises.writeFile(fullPath, "ping");
await fs.promises.readFile(fullPath);
+1 -1
View File
@@ -18,7 +18,7 @@ async function getS3Client(config: S3Config) {
region: config.region ?? "us-east-1",
accessKey: config.accessKey,
secretKey: config.secretKey,
port: config.port ?? 443,
port: config.port ? Number(config.port) : 443,
useSSL: config.ssl ?? true,
});
}
@@ -19,8 +19,6 @@ export const useUpdateCheck = () => {
staleTime: 1000 * 60 * 60,
});
console.log("newRelease", newRelease);
useEffect(() => {
if (!newRelease) return;
@@ -1,215 +0,0 @@
"use server";
import {mkdir, writeFile} from "fs/promises";
import path from "path";
import * as fs from "node:fs";
import {getServerUrl} from "@/utils/get-server-url";
import {createPresignedUrlToDownload, deleteFileFromBucket, saveFileInBucket} from "@/utils/s3-file-management";
import {env} from "@/env.mjs";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {unlink} from "fs/promises";
import {action} from "@/lib/safe-actions/actions";
const privateLocalDir = "private/uploads/files/";
const privateS3Dir = "backups/";
export async function uploadLocalPrivate(fileName: string, buffer: any) {
try {
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
await writeFile(path.join(process.cwd(), privateLocalDir, fileName), buffer);
return {
success: true,
message: "File uploaded successfully",
filePath: path.join(privateLocalDir, fileName),
};
} catch (error) {
console.error("Error occurred:", error);
throw new Error("An error occurred while importing the private file");
}
}
export async function uploadS3Private(fileName: string, buffer: any, bucketName: string) {
try {
await saveFileInBucket({
bucketName,
fileName: `${privateS3Dir}${fileName}`,
file: buffer,
});
return {
success: true,
filePath: `${privateS3Dir}${fileName}`,
message: "File uploaded successfully",
};
} catch (error) {
console.error("Error occurred:", error);
throw new Error("An error occurred while importing the private file");
}
}
export async function deleteFileS3Private(fileName: string, bucketName: string) {
try {
await deleteFileFromBucket({
bucketName,
fileName: `${privateS3Dir}${fileName}`,
});
return {
success: true,
message: "File deleted successfully",
};
} catch (error) {
console.error("Error occurred:", error);
throw new Error("An error occurred while deleting the private file");
}
}
/**
* Delete a file from local private storage
*/
export async function deleteLocalPrivate(fileName: string) {
try {
const filePath = path.join(process.cwd(), privateLocalDir, fileName);
await unlink(filePath);
return {
success: true,
message: `File '${fileName}' deleted successfully`,
};
} catch (error: any) {
if (error.code === "ENOENT") {
return {
success: false,
message: `File '${fileName}' not found`,
};
}
console.error("Error occurred while deleting file:", error);
throw new Error("An error occurred while deleting the file");
}
}
// export async function getFileUrlPresignedLocal(fileName: string) {
// try {
// const filePath = path.join(privateLocalDir, fileName);
// await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
//
// if (!fs.existsSync(filePath)) {
// console.error("File not found at:", filePath);
// return `File not found at: ${filePath}`;
// }
// const crypto = require("crypto");
// const baseUrl = getServerUrl();
//
// const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
// const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
// return `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`;
// } catch (error) {
// throw error;
// }
// }
export async function getFileUrlPresignedS3(fileName: string) {
try {
return await createPresignedUrlToDownload({
bucketName: env.S3_BUCKET_NAME!,
fileName: fileName,
});
} catch (error) {
throw error;
}
}
export const getFileUrlPresignedLocal = action
.schema(z.object({
dir: z.string().optional(),
fileName: z.string()
}))
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
try {
const filePath = path.join(parsedInput.dir ? parsedInput.dir : privateLocalDir, parsedInput.fileName);
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
throw new Error(`File not found at: ${filePath}`);
}
const crypto = require("crypto");
const baseUrl = getServerUrl();
const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
const token = crypto.createHash("sha256").update(`${parsedInput.fileName}${expiresAt}`).digest("hex");
return {
success: true,
value: `${baseUrl}/api/files/${parsedInput.fileName}?token=${token}&expires=${expiresAt}`,
actionSuccess: {
message: "Successfully retrieved presigned URL Local",
messageParams: {fileName: parsedInput.fileName},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to generate presigned URL",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {fileName: parsedInput.fileName},
},
};
}
});
export const getFileUrlPreSignedS3Action = action
.schema(z.string())
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
try {
const data = await createPresignedUrlToDownload({
bucketName: env.S3_BUCKET_NAME!,
fileName: parsedInput,
});
return {
success: true,
value: data.url,
actionSuccess: {
message: "Successfully retrieved presigned URL",
messageParams: {fileName: parsedInput},
},
};
} catch (error) {
const isNotFound = error instanceof Error && error.message.includes("File does not exist");
const logContext = {
file: parsedInput,
reason: error instanceof Error ? error.message : "Unknown",
};
console.error("Presigned URL generation failed:", logContext);
return {
success: false,
actionError: {
message: isNotFound
? "File not found in S3 bucket"
: "Failed to generate presigned URL",
status: isNotFound ? 404 : 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {fileName: parsedInput},
},
};
}
});
+15 -2
View File
@@ -192,6 +192,8 @@ export const auth = betterAuth({
session: {
create: {
before: async (session, context) => {
const userId = session.userId;
let memberships = await db.query.member.findMany({
@@ -231,6 +233,9 @@ export const auth = betterAuth({
// }
// },
after: async (session) => {
console.log("session", session);
const user = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.id, session.userId),
});
@@ -244,6 +249,12 @@ export const auth = betterAuth({
return;
}
const lastDiff = user.lastConnectedAt
? new Date(session.createdAt).getTime() - new Date(user.lastConnectedAt).getTime()
: Infinity;
if (lastDiff < 30000) return;
if (user.role === "pending") return;
const deviceInfo = getDeviceDetails(session.userAgent);
@@ -509,7 +520,8 @@ export const checkSlugOrganization = async (slug: string) => {
});
return status;
} catch {}
} catch {
}
};
export const getActiveMember = async () => {
@@ -532,5 +544,6 @@ export const setActiveOrganization = async (slug: string) => {
organizationSlug: slug,
},
});
} catch {}
} catch {
}
};
+28
View File
@@ -0,0 +1,28 @@
import {db} from "@/db";
import {and, eq, isNotNull, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {withUpdatedAt} from "@/db/utils";
export const backupCleanTask = async () => {
try {
const backups = await db.query.backup.findMany({
where: and(
isNotNull(drizzleDb.schemas.backup.deletedAt),
eq(drizzleDb.schemas.backup.status, "ongoing")
)
});
console.log(`Backups to clean: ${backups.length}`);
for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
status: "failed",
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
} catch (e: any) {
console.error("Backup cleanup failed:", e);
throw e;
}
};
+2
View File
@@ -70,6 +70,8 @@ export const deleteBackupCronAction = action
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
deletedAt: new Date(),
status: backup.status == "ongoing" ? "failed" : backup.status
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
+10
View File
@@ -1,6 +1,7 @@
import cron from "node-cron";
import {retentionCleanTask} from "@/lib/tasks/database";
import {env} from "@/env.mjs";
import {backupCleanTask} from "@/lib/tasks/cleaning";
export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
@@ -10,4 +11,13 @@ export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
} catch (err) {
console.error(`[CRON] Error:`, err);
}
});
export const cleaningJob = cron.schedule("* * * * *", async () => {
try {
console.log("Cleaning Job : Starting task");
await backupCleanTask();
} catch (err) {
console.error(`[CRON] Error:`, err);
}
});
+10
View File
@@ -39,3 +39,13 @@ export function buildOrganizationWithMembers(
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
+1 -2
View File
@@ -2,8 +2,7 @@
import {getMasterServerKeyContent} from "@/features/keys/keys.action";
export async function generateEdgeKey(serverUrl: string, agentId: string): Promise<string> {
const masterKey = getMasterServerKeyContent()
console.log("Master server key: ", masterKey)
const masterKey = await getMasterServerKeyContent()
const edgeKeyData = {
serverUrl,
agentId,
+8 -1
View File
@@ -2,7 +2,7 @@ import {env} from "@/env.mjs";
import {db, makeMigration} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {retentionJob} from "@/lib/tasks";
import {cleaningJob, retentionJob} from "@/lib/tasks";
import {generateRSAKeys, getOrCreateMasterKey} from "@/utils/rsa-keys";
import {StorageProviderKind} from "@/features/storages/types";
@@ -17,6 +17,7 @@ export async function init() {
await createSettingsIfNotExist()
console.log("====Initialization completed====");
await setupCronJobs()
await setupCleaningJobs()
}
async function setupCronJobs() {
@@ -25,6 +26,12 @@ async function setupCronJobs() {
console.log("==== Cron job started ====");
}
async function setupCleaningJobs() {
console.log("==== Setting up Cleaning Jobs ====");
cleaningJob.start();
console.log("==== Cleaning job started ====");
}
async function createSettingsIfNotExist() {
await db.transaction(async (tx) => {
const systemSettingsValues = {
+4 -4
View File
@@ -3,6 +3,7 @@ import path from 'path';
import {generateKeyPair} from 'crypto';
import {promisify} from 'util';
import {randomBytes} from 'crypto';
import {env} from "@/env.mjs";
const generateKeyPairAsync = promisify(generateKeyPair);
@@ -13,9 +14,10 @@ const generateKeyPairAsync = promisify(generateKeyPair);
* @param {string} [dir] path to directory
* @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>}
*/
export async function generateRSAKeys(dir = path.join(process.cwd(), 'private/keys')) {
export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH, '/keys')) {
await fs.mkdir(dir, {recursive: true});
const privateKeyPath = path.join(dir, 'server_private.pem');
const publicKeyPath = path.join(dir, 'server_public.pem');
@@ -47,7 +49,7 @@ export async function generateRSAKeys(dir = path.join(process.cwd(), 'private/ke
* @param {string} [filePath] Path to store the key
* @returns {Promise<Buffer>} The master key
*/
export async function getOrCreateMasterKey(filePath = path.join(process.cwd(), 'private/keys', 'master_key.bin')) {
export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH, '/keys', 'master_key.bin')) {
await fs.mkdir(path.dirname(filePath), {recursive: true});
@@ -61,8 +63,6 @@ export async function getOrCreateMasterKey(filePath = path.join(process.cwd(), '
const key = randomBytes(32); // 256-bit key
console.log(key)
await fs.writeFile(filePath, key, {mode: 0o600});
console.log(`Master key generated at ${filePath}`);
-243
View File
@@ -1,243 +0,0 @@
import * as Minio from "minio";
import {env} from "@/env.mjs";
import internal from "node:stream";
import stream from "node:stream";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {eq} from "drizzle-orm";
async function getS3Client() {
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("S3 settings not found in database.");
}
const baseConfig = {
endPoint: settings?.s3EndPointUrl ?? "",
accessKey: settings?.s3AccessKeyId ?? "",
secretKey: settings?.s3SecretAccessKey ?? "",
};
return new Minio.Client({
...baseConfig,
port: Number(env.S3_PORT ?? 0),
useSSL: env.S3_USE_SSL === "true",
});
}
export async function checkMinioAlive() {
try {
console.log("Check MinioAlive");
const s3Client = await getS3Client();
const buckets = await s3Client.listBuckets();
console.log("MinIO is up and running. Buckets:", buckets);
return {message: true};
} catch (error) {
console.error("Error connecting to MinIO:", error);
return {error: error};
}
}
export async function createBucketIfNotExists(bucketName: string) {
const s3Client = await getS3Client();
const bucketExists = await s3Client.bucketExists(bucketName);
if (!bucketExists) {
console.log(`Creating bucket ${bucketName}`);
await s3Client.makeBucket(bucketName);
}
}
/**
* Delete a file from a bucket
* @param bucketName name of the bucket
* @param fileName name of the file
* @returns true if deleted, false if not
*/
export async function deleteFileFromBucket({
bucketName,
fileName,
}: {
bucketName: string;
fileName: string;
}): Promise<boolean> {
const s3Client = await getS3Client();
try {
const fileExists = await checkFileExistsInBucket({ bucketName, fileName });
if (!fileExists) {
console.warn(`File not found: ${bucketName}/${fileName}`);
return false;
}
await s3Client.removeObject(bucketName, fileName);
console.log(`Deleted file: ${bucketName}/${fileName}`);
return true;
} catch (error: any) {
console.error("Error deleting file from bucket:", {
bucketName,
fileName,
error: error.message,
});
return false;
}
}
/**
* Save file in S3 bucket
* @param bucketName name of the bucket
* @param fileName name of the file
* @param file file to save
*/
export async function saveFileInBucket({bucketName, fileName, file}: {
bucketName: string;
fileName: string;
file: Buffer | internal.Readable
}) {
await checkMinioAlive();
await createBucketIfNotExists(bucketName);
const fileExists = await checkFileExistsInBucket({
bucketName,
fileName,
});
console.log("File exists:", fileExists);
if (fileExists) {
throw new Error("File already exists");
}
const s3Client = await getS3Client();
return await s3Client.putObject(bucketName, fileName, file);
}
export async function getObjectFromClient({
bucketName,
fileName,
}: {
bucketName: string;
fileName: string;
}): Promise<stream.Readable> {
const s3 = await getS3Client();
return await s3.getObject(bucketName, fileName);
}
export async function checkFileExistsInBucket({
bucketName,
fileName,
}: {
bucketName: string;
fileName: string;
}): Promise<boolean> {
const s3 = await getS3Client();
try {
const stat = await s3.statObject(bucketName, fileName);
return !!stat;
} catch (error: any) {
if (error.code === 'NoSuchKey' || error.message?.includes('not found')) {
return false;
}
return false;
}
}
/**
* Generate presigned urls for uploading files to S3
* @param files files to upload
* @returns promise with array of presigned urls
*/
export async function createPresignedUrlToUpload({
bucketName,
fileName,
expiry = 60 * 60, // 1 hour
}: {
bucketName: string;
fileName: string;
expiry?: number;
}) {
// Create bucket if it doesn't exist
await createBucketIfNotExists(bucketName);
const s3Client = await getS3Client();
return await s3Client.presignedPutObject(bucketName, fileName, expiry);
}
export async function createPublicBucket({bucketName}: { bucketName: string }) {
const s3Client = await getS3Client();
try {
const exists = await s3Client.bucketExists(bucketName);
if (!exists) {
await s3Client.makeBucket(bucketName);
console.log(`Bucket ${bucketName} created successfully.`);
} else {
console.log(`Bucket ${bucketName} already exists.`);
}
const policy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: `arn:aws:s3:::${bucketName}/*`,
},
],
};
await s3Client.setBucketPolicy(bucketName, JSON.stringify(policy));
console.log(`Bucket ${bucketName} is now public.`);
} catch (error) {
console.error("Error creating bucket:", error);
}
}
/**
* Generate a presigned URL for downloading a file from a private S3 bucket
* @param bucketName name of the bucket
* @param fileName name of the file
* @param expiry expiry time in seconds (default 1 hour)
* @returns presigned download URL
*/
export async function createPresignedUrlToDownload({
bucketName,
fileName,
expiry = 60 * 60,
}: {
bucketName: string;
fileName: string;
expiry?: number;
}) {
try {
const s3Client = await getS3Client();
console.debug("Checking if file exists in bucket:", {bucketName, fileName});
const fileExists = await checkFileExistsInBucket({bucketName, fileName});
if (!fileExists) {
console.warn("File does not exist:", {bucketName, fileName});
throw new Error("File does not exist in the bucket.");
}
const presignedUrl = await s3Client.presignedGetObject(bucketName, fileName, expiry);
console.debug("Generated pre signed URL:", presignedUrl);
return {url: presignedUrl};
} catch (err: any) {
console.error("Error in createPreSignedUrlToDownload:", {
bucketName,
fileName,
errorMessage: err?.message,
});
throw {error: err.message ?? "Unknown error"};
}
}