Compare 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
charlesgauthereau 7ab213e5e4 chore(release): 1.2.5 2026-02-13 14:35:11 +01:00
Charles GTEandGitHub f0eafb1162 Merge pull request #74 from Portabase/dev
dev
2026-02-13 14:31:52 +01:00
charlesgauthereau ad5baa7d6f Merge branch 'main' into dev 2026-02-13 14:31:03 +01:00
Charles GTEandGitHub 5840e30afb Merge pull request #73 from Portabase/feat/ui-ux-refactoring
feat/ui-ux-refactoring
2026-02-13 14:30:16 +01:00
charlesgauthereau 8f95606dd7 fix: review 2026-02-13 14:28:55 +01:00
Théo LAGACHE ca9bc7ae94 fix: policies reload 2026-02-13 11:24:53 +01:00
charlesgauthereau 1c97c97fa1 fix: refactoring. 2026-02-12 19:07:34 +01:00
Théo LAGACHE 6f6c1c7c52 fix 2026-02-12 15:48:51 +01:00
charlesgauthereau 7b9cb2ecb9 Merge remote-tracking branch 'origin/feat/fix-react-cve' into feat/agent-update 2026-02-11 19:42:57 +01:00
charlesgauthereau fc029af654 Merge branch 'feature' into feat/agent-update 2026-02-11 19:42:49 +01:00
Théo LAGACHE 110a69dc4b fix: react cve 19.2.4 2026-02-11 15:43:03 +01:00
Théo LAGACHE a61791fdb2 update: fix ui sidebar 2026-02-11 14:42:07 +01:00
charlesgauthereau d9badeacba Merge branch 'feature' into feat/agent-update 2026-02-11 12:21:22 +01:00
Théo LAGACHE d24e534828 update: ui 2026-02-10 17:02:06 +01:00
Théo LAGACHE 80bdca138e add: agent update notifier, pagination 2026-02-10 15:52:08 +01:00
95 changed files with 4175 additions and 2361 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-rc.16
date-released: "2026-02-12"
version: 1.3.0-rc.1
date-released: "2026-02-23"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Soluce Technologies
Copyright 2024 Portabase
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+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 -1
View File
@@ -16,7 +16,7 @@ export default async function GuardPage() {
return (
<TooltipProvider>
<CardAuth className="w-full max-w-md">
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1>
+13 -6
View File
@@ -2,6 +2,8 @@ import React from "react";
import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
import {env} from "@/env.mjs";
import {Heart} from "lucide-react";
export default async function Layout({children}: { children: React.ReactNode }) {
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
}
return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md">
<AuthLogoSection/>
<div>{children}</div>
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
<div className="flex flex-col items-center justify-center flex-1">
<div className="mx-auto w-full max-w-md">
<AuthLogoSection/>
<div className="mt-4">{children}</div>
</div>
</div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
Powered by <span className="font-medium">Soluce Technologies</span>
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
<p className="flex items-center justify-center gap-1">
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer>
</div>
)
+4 -3
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import {CardAuth} from "@/features/layout/card-auth";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
return (
<TooltipProvider>
<Card className="w-full max-w-md shadow-lg">
<CardAuth className="w-full">
<CardHeader className="space-y-4">
<div className="space-y-1 text-center">
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
@@ -53,7 +54,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
<CardContent>
<ResetPasswordForm />
</CardContent>
</Card>
</CardAuth>
</TooltipProvider>
);
}
@@ -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>
@@ -6,8 +6,6 @@ import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
@@ -85,12 +83,6 @@ export default async function RoutePage(props: PageParams<{
notFound();
}
const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -110,8 +102,8 @@ export default async function RoutePage(props: PageParams<{
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={activeOrganizationChannels}
activeOrganizationStorageChannels={activeOrganizationStorageChannels}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</Page>
@@ -66,7 +66,7 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={6}
cardsPerPage={9}
numberOfColumns={3}
/>
) : isMember ? (
+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);
+9
View File
@@ -145,4 +145,13 @@
h1, h2, h3, h4, h5, h6 {
@apply font-title;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
-1
View File
@@ -6,7 +6,6 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>;
+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 &
+79 -79
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.2.5-rc.16",
"version": "1.3.0-rc.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -14,52 +14,52 @@
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.17",
"@types/nodemailer": "^6.4.22",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"dockerode": "^4.0.9",
"dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
@@ -67,65 +67,65 @@
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.5",
"motion": "^12.23.24",
"minio": "^8.0.6",
"motion": "^12.34.0",
"next": "16.1.5",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.3.1",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"prettier": "^3.8.0",
"react": "^19.2.0",
"node-forge": "^1.3.3",
"nodemailer": "^7.0.13",
"npm-check-updates": "^18.3.1",
"pg": "^8.18.0",
"prettier": "^3.8.1",
"react": "^19.2.4",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-dom": "^19.2.4",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.71.1",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.3",
"recharts": "^2.15.4",
"sharp": "^0.34.5",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"swiper": "^12.0.3",
"tailwind-merge": "^3.3.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"swiper": "^12.1.0",
"tailwind-merge": "^3.4.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
"ws": "^8.19.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1",
"@tailwindcss/postcss": "^4.1.7",
"@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.18",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/node-forge": "^1",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"@types/node": "^22.19.11",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1",
"esbuild": "^0.27.2",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"framer-motion": "^12.24.7",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"drizzle-kit": "^0.31.9",
"esbuild": "^0.27.3",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
+923 -861
View File
File diff suppressed because it is too large Load Diff
+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),
});
+1 -1
View File
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
import { cn } from '@/lib/utils';
const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-4 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
+1 -1
View File
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto scrollbar-hide group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
@@ -41,9 +41,6 @@ export const AuthLogoSection = () => {
/>
)}
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div>
);
};
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Send reset link
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -113,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
</FormItem>
)}
/>
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login
</ButtonWithLoading>
</Form>
@@ -82,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
/>
<div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
<ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Reset
</ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -55,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-balance text-muted-foreground">Enter your informations bellow to register</p>
<p className="text-balance text-muted-foreground">Enter your informations below to register</p>
</div>
</CardHeader>
<CardContent>
@@ -138,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
</FormItem>
)}
/>
<Button type="submit" disabled={mutation.isPending}>
<Button type="submit" className="h-11" disabled={mutation.isPending}>
Sign up
</Button>
<div className="mt-4 text-center text-sm">
@@ -47,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-balance text-muted-foreground">Fill information bellow to change your
<p className="text-balance text-muted-foreground">Fill information below to change your
password</p>
</div>
</CardHeader>
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
<GithubButton
initialStars={0}
targetStars={stars}
label="Github Stars"
label=""
size="sm"
separator={true}
roundStars={true}
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
export const PaginationNavigation = (props: paginationNavigationProps) => {
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
if (totalPages <= 1) return null;
return (
<Pagination className={cn("", className)}>
<PaginationContent>
@@ -14,6 +14,10 @@ interface tablePaginationProps {
export function TablePagination(props: tablePaginationProps) {
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
const totalPages = table.getPageCount();
if (totalPages <= 1) return null;
return (
<div
className={cn("flex gap-x-4", className)}
@@ -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");
@@ -136,7 +136,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
<input type="hidden" {...form.register("config.refreshToken")} />
<div className="flex items-center gap-3">
<Button type="button" onClick={handleConnect}>
<Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
</Button>
@@ -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>
);
@@ -1,11 +1,11 @@
"use client";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button";
import {Copy, Check} from "lucide-react";
import {Copy, Check, Eye, EyeOff, Terminal, Key, Info} from "lucide-react";
import {useState} from "react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
export type AgentCardKeyProps = {
edgeKey: string;
@@ -15,74 +15,131 @@ export type AgentCardKeyProps = {
export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => {
const [isCopiedKey, setIsCopiedKey] = useState(false);
const [isCopiedCommand, setIsCopiedCommand] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const command = `portabase agent "${agentName}" --key ${edgeKey}`;
const maskedKey = "••••••••••••••••••••••••••••••••";
const handleCopy = async (text: string, setter: (v: boolean) => void) => {
await copyToClipboardWithMeta(text);
setter(true);
setTimeout(() => setter(false), 2000);
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return (
<div className="grid gap-6 py-2">
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
1. Registration Key
</Label>
<div className="flex items-center gap-2">
<Input
readOnly
value={edgeKey}
onFocus={handleFocus}
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
className="shrink-0"
type="button"
>
{isCopiedKey ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Use this key for manual configuration of your agent.
</p>
</div>
<div className="flex flex-col gap-4 py-2">
<Card className="border-muted/60 shadow-none py-0">
<CardHeader className="px-4 pt-4">
<CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
CLI Setup
</CardTitle>
<CardDescription className="text-xs leading-relaxed">
To setup your agent using the CLI, copy the command below and paste it in your terminal.
Make sure you have the PortaBase CLI installed. If not, you can <a href="https://portabase.io/docs/cli" target="_blank" className="underline hover:text-primary">install it here</a>.
</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
readOnly
value={isVisible ? command : `portabase agent "${agentName}" --key ${maskedKey}`}
onFocus={(e) => {
setIsVisible(true);
handleCopy(command, setIsCopiedCommand);
e.currentTarget.select();
}}
onClick={(e) => e.currentTarget.select()}
onBlur={() => {
setIsVisible(false);
setIsCopiedCommand(false);
}}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(command, setIsCopiedCommand)}
className="shrink-0 gap-2 h-10 px-4"
type="button"
>
{isCopiedCommand ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2 pt-2">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
2. Automatic Setup (CLI)
</Label>
<div className="flex items-center gap-2">
<Input
readOnly
value={command}
onFocus={handleFocus}
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(command, setIsCopiedCommand)}
className="shrink-0"
type="button"
>
{isCopiedCommand ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Run this command on your server to automatically register the agent.
</p>
</div>
</div>
<Card className="border-muted/60 shadow-none py-0">
<CardHeader className="px-4 pt-4">
<CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
Manual Setup
</CardTitle>
<CardDescription className="text-xs leading-relaxed">
Use this key to manually configure your agent in your configuration file.
If you need help for manual configuration, you can follow our guide in the <a href="https://portabase.io/docs" target="_blank" className="underline hover:text-primary">documentation</a>.
</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
readOnly
value={isVisible ? edgeKey : maskedKey}
onFocus={(e) => {
setIsVisible(true);
handleCopy(edgeKey, setIsCopiedKey);
e.currentTarget.select();
}}
onClick={(e) => e.currentTarget.select()}
onBlur={() => {
setIsVisible(false);
setIsCopiedKey(false);
}}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7 hover:bg-transparent"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
className="shrink-0 gap-2 h-10 px-4 "
type="button"
>
{isCopiedKey ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -6,10 +6,12 @@ import {Card} from "@/components/ui/card";
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, AlertCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = {
data: AgentWith;
@@ -20,6 +22,8 @@ export const AgentCard = (props: agentCardProps) => {
const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false);
const {newRelease, isUpdateAvailable} = useAgentUpdateCheck(agent.version);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -44,9 +48,23 @@ export const AgentCard = (props: agentCardProps) => {
{isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3>
{agent.version && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</Badge>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</Badge>
{isUpdateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 bg-yellow-500/10 rounded-full border border-yellow-500/20 text-yellow-600">
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Update available: {newRelease?.tag_name}</p>
</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
@@ -36,7 +36,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
},
staleTime: 0,
gcTime: 0,
refetchInterval: 5000,
refetchInterval: 1000,
});
const agent = data?.data ?? initialAgent;
@@ -69,7 +69,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
<div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300 data-[state=open]:ring-1 data-[state=open]:ring-primary/20">
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<AccordionTrigger className="hover:no-underline py-4 group">
<div className="flex items-center gap-3">
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
@@ -90,7 +90,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</Accordion>
</div>
<div className="space-y-6">
{agent.databases.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between px-1">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
@@ -107,6 +108,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
cardItem={AgentDatabaseCard}
/>
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
"use client";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
@@ -15,6 +16,7 @@ export type BackupButtonProps = {
export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile()
const mutation = useMutation({
@@ -23,6 +25,7 @@ export const BackupButton = (props: BackupButtonProps) => {
if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
}
@@ -1,26 +1,36 @@
"use client";
import {PropsWithChildren, ReactNode, useState} from "react";
import { useRouter } from "next/navigation";
import { CircleUser, LogOut } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { LogOut, User } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client";
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
import {Account, Session, User} from "@/db/schema/02_user";
import {Account, Session, User as UserType} from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config";
export type LoggedInDropdownProps = PropsWithChildren<{
user: User;
user: UserType;
sessions: Session[];
currentSession: Session;
accounts: Account[];
children: ReactNode;
providers: AuthProviderConfig[]
}>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -34,34 +44,50 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen}
providers={providers}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}>
<div className="flex justify-start items-center gap-2">
<CircleUser size={16} />
<span>Account</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex justify-start items-center gap-2">
<LogOut size={16} className="text-red-500" />
<span className="text-red-500">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start"
side="top"
sideOffset={8}
>
<DropdownMenuItem
onClick={() => setIsModalOpen(!isModalOpen)}
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
>
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Account Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
@@ -42,4 +42,4 @@ export function AppSidebar() {
<SideBarFooterCredit/>
</Sidebar>
);
}
}
@@ -7,20 +7,17 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {authClient} from "@/lib/auth/auth-client";
export const SidebarMenuCustomMain = () => {
const BASE_URL = `/dashboard`;
const {data: activeOrganization} = authClient.useActiveOrganization();
const {data: organizations} = authClient.useListOrganizations();
const {data: session, isPending, error} = authClient.useSession();
const member = authClient.useActiveMember();
if (isPending) return null;
@@ -107,6 +104,23 @@ export const SidebarMenuCustomMain = () => {
});
}
items.push(
{
label: "Resources",
type: "list",
group_content: [
{
title: "Documentation",
url: "https://portabase.io/docs",
icon: BookOpen,
type: "item",
redirect: true,
not_from_base_url: true,
}
],
},
)
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
};
};
@@ -16,4 +16,4 @@ export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
)}
</>
);
};
};
@@ -16,10 +16,9 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Badge} from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {useRouter} from "next/navigation";
import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction
@@ -39,9 +38,9 @@ type BackupActionsFormProps = {
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const isMobile = useIsMobile();
const {closeModal} = useBackupModal();
const queryClient = useQueryClient();
const router = useRouter();
const form = useZodForm({
schema: BackupActionsSchema,
@@ -75,6 +74,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") {
const url = inner.value
if (typeof url === "string") {
@@ -92,6 +92,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (action === "delete") {
toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message ?? "An error occurred.");
@@ -113,6 +114,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal()
} else {
toast.error(inner?.actionError?.message);
@@ -175,24 +177,26 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(storage.storageChannel?.provider || "")}
<h3 className="font-medium text-foreground">
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono">
{storage.storageChannel?.provider}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getChannelIcon(storage.storageChannel?.provider || "")}
</div>
<h3 className="font-medium text-foreground truncate">
{storage.storageChannel?.name}
</h3>
<Badge variant="secondary"
className="text-xs font-mono shrink-0">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div>
</div>
</div>
@@ -5,6 +5,8 @@ import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
export const getDatabaseDataAction = userAction
.schema(
@@ -19,6 +21,9 @@ export const getDatabaseDataAction = userAction
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
@@ -45,10 +50,23 @@ export const getDatabaseDataAction = userAction
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
let activeOrganizationChannels = [];
let activeOrganizationStorageChannels = [];
if (database?.project?.organizationId) {
const organizationChannels = await getOrganizationChannels(database.project.organizationId);
activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(database.project.organizationId);
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return {
database,
backups,
restorations,
activeOrganizationChannels,
activeOrganizationStorageChannels,
stats: {
totalBackups,
availableBackups,
@@ -14,6 +14,7 @@ import {Switch} from "@/components/ui/switch";
import {Card} from "@/components/ui/card";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
import {useRouter} from "next/navigation";
import {
ChannelKind,
getChannelIcon,
@@ -48,6 +49,7 @@ export const ChannelPoliciesForm = ({
kind
}: ChannelPoliciesFormProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind);
@@ -130,6 +132,7 @@ export const ChannelPoliciesForm = ({
onSuccess: () => {
toast.success("Policies saved successfully");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
});
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {Database} from "@/db/schema/07_database";
@@ -18,6 +19,7 @@ export type CronButtonProps = {
export const CronButton = (props: CronButtonProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const [open, setOpen] = useState(false);
@@ -27,6 +29,7 @@ export const CronButton = (props: CronButtonProps) => {
onSuccess: () => {
toast.success(`Method updated successfully.`);
queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] });
router.refresh();
},
onError: () => {
toast.error(`An error occurred while updating backup method.`);
@@ -1,6 +1,7 @@
import {AdvancedCronSelect} from "./advanced-cron-select";
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
@@ -15,6 +16,7 @@ export type CronInputProps = {
export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const queryClient = useQueryClient();
const router = useRouter();
const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
@@ -22,6 +24,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
toast.success(`Cron updated successfully.`);
onSuccess?.()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: () => {
toast.error(`An error occurred while updating cron value.`);
@@ -2,6 +2,7 @@
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
@@ -16,6 +17,7 @@ type UploadRetentionZoneProps = {
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
const [file, setFile] = useState<File | null>(null);
@@ -37,6 +39,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
@@ -65,7 +68,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
) : (
<DropZoneFile
accept={acceptDbImportFiles}
maxSize={500 * 1024 * 1024}
maxSize={2 * 1024 * 1024 * 1024}
maxFiles={1}
description="Import database backup"
fileKind="Database file (.sql, .dump)"
@@ -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,
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/form";
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
import {toast} from "sonner";
@@ -28,6 +29,7 @@ export type BackupRetentionSettingsFormProps = {
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const defaultValuesFormatted: RetentionSettings = {
type: defaultValues?.type,
@@ -55,6 +57,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
onSuccess: () => {
toast.success("Retention policy updated successfully.");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: () => {
toast.error("An error occurred while updating retention policy.");
@@ -18,7 +18,7 @@ import {Skeleton} from "@/components/ui/skeleton"
export function OrganizationCombobox() {
const router = useRouter()
const {isMobile, state} = useSidebar()
const {state} = useSidebar()
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
const {
data: activeOrganization,
@@ -95,7 +95,7 @@ export function OrganizationCombobox() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
align="start"
side="bottom"
sideOffset={4}
@@ -113,7 +113,7 @@ export function OrganizationCombobox() {
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}>
<div className={cn(
"flex size-9 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
"flex size-9 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
org.logo ? "bg-transparent border-transparent" : "",
isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}>
@@ -126,7 +126,7 @@ export function OrganizationCombobox() {
)}/>
)}
</div>
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className={cn(
"text-sm max-w-42.5 truncate font-medium leading-none",
isActive ? "text-primary" : ""
@@ -150,7 +150,7 @@ export function OrganizationCombobox() {
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
<Plus className="size-4"/>
</div>
<div className="font-medium">Create new organization</div>
<div className="font-medium">Create organization</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -145,7 +145,7 @@ export default function TwoFactorForm({ onSuccess, onSuccessData }: TwoFactorFor
/>
<div className="flex flex-col gap-3 pt-2">
<Button type="submit" disabled={isPending}>
<Button type="submit" className="h-11" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify
</Button>
@@ -21,7 +21,7 @@ export function ProfileAppearance() {
}
function ThemeSelector() {
const {theme, setTheme} = useTheme();
const {theme} = useTheme();
return (
@@ -39,7 +39,6 @@ function ThemeSelector() {
isActive ? "border-primary bg-primary/5" : "border-muted/40"
)}
onClick={async () => {
// setTheme(item.value)
await authClient.updateUser({theme: item.value});
}}
>
@@ -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)}
@@ -37,6 +37,7 @@ export type DatabaseContentProps = {
export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal();
const {data} = useQuery({
queryKey: ["database-data", props.database.id],
queryFn: async () => {
@@ -44,12 +45,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
stats: {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
@@ -58,12 +63,14 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
},
staleTime: 0,
gcTime: 0,
refetchInterval: 5000,
refetchInterval: 1000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
const stats = data?.stats ?? {
totalBackups: props.totalBackups,
availableBackups: props.availableBackups,
@@ -91,14 +98,14 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
database={database}
kind={"notification"}
icon={<Megaphone/>}
channels={props.activeOrganizationChannels}
channels={activeOrganizationChannels}
organizationId={props.organizationId}
/>
<ChannelPoliciesModal
database={database}
icon={<HardDrive/>}
kind={"storage"}
channels={props.activeOrganizationStorageChannels}
channels={activeOrganizationStorageChannels}
organizationId={props.organizationId}
/>
<ImportModal database={database}/>
@@ -116,16 +123,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi
successRate={stats.successRate}
database={database}
<DatabaseKpi
successRate={stats.successRate}
database={database}
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
<DatabaseTabs
activeMember={props.activeMember}
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
+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')
},
});
@@ -0,0 +1,19 @@
"use client";
import {useQuery} from "@tanstack/react-query";
import {getNewAgentRelease} from "@/features/updates/services/github";
export const useAgentUpdateCheck = (currentVersion?: string | null) => {
const {data: newRelease, isLoading} = useQuery({
queryKey: ["agent-new-release", currentVersion],
queryFn: () => currentVersion ? getNewAgentRelease(currentVersion) : Promise.resolve(null),
staleTime: 1000 * 60 * 60,
enabled: !!currentVersion,
});
return {
newRelease,
isLoading,
isUpdateAvailable: !!newRelease,
};
};
-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,
});
}
+62 -26
View File
@@ -1,41 +1,77 @@
"use client"
import { Moon, Sun, Check, SunMoon } from "lucide-react"
import { useTheme } from "next-themes"
import { authClient } from "@/lib/auth/auth-client"
import * as React from "react"
import {Moon, Sun} from "lucide-react"
import {Button} from "@/components/ui/button"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
const themes = [
{ id: "light", icon: Sun, label: "Light" },
{ id: "dark", icon: Moon, label: "Dark" },
{ id: "system", icon: SunMoon, label: "System" },
] as const
export function ModeToggle() {
const { theme } = useTheme()
const setTheme = async (theme: "light" | "dark" | "system") => {
toast.success("Your theme preference has been updated.");
await authClient.updateUser({theme: theme});
};
const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
await authClient.updateUser({ theme: newTheme })
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"/>
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"/>
<Button variant="ghost" size="icon" className="size-8 rounded-full border border-input bg-transparent shadow-xs transition-transform active:scale-95">
{theme === "light" ? <Sun className="size-4" /> : theme === "dark" ? <Moon className="size-4" /> : <SunMoon className="size-4" />}
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
<DropdownMenuContent align="end" className="min-w-37.5 rounded-xl border-2 border-border p-1 shadow-none">
{themes.map((t) => {
const ThemeIcon = t.icon
const isActive = theme === t.id
return (
<DropdownMenuItem
key={t.id}
onClick={() => handleThemeChange(t.id)}
className={cn(
"group gap-2 p-2 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
isActive
? "bg-primary/10 text-primary border border-primary/20"
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}
>
<div className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
isActive ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}>
<ThemeIcon className={cn(
"size-4",
isActive ? "text-primary-foreground" : "text-muted-foreground"
)} />
</div>
<span className={cn(
"text-sm font-medium leading-none flex-1",
isActive ? "text-primary" : ""
)}>
{t.label}
</span>
{isActive && (
<div className="flex size-4 items-center justify-center rounded-full bg-primary shadow-sm ml-auto">
<Check className="size-2.5 text-primary-foreground" strokeWidth={3} />
</div>
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
}
@@ -18,9 +18,9 @@ export const UpdateNotification = () => {
<SidebarGroup className="py-0">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem className="px-2">
<SidebarMenuItem>
<div
className="relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20">
className="w-[var(--radix-popper-anchor-width)] relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20">
<button
onClick={dismissUpdate}
className="absolute right-2 top-2 rounded-md p-0.5 text-muted-foreground/50 hover:bg-sidebar-accent hover:text-foreground transition-colors"
@@ -37,10 +37,12 @@ export const UpdateNotification = () => {
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold leading-none">Update available</span>
<span
className="text-[10px] text-muted-foreground font-medium px-1 py-0.5 bg-primary/10 rounded-full">
v{newRelease.tag_name.replace(/^v/, "")}
</span>
{newRelease.tag_name && (
<span
className="text-[10px] text-muted-foreground font-medium px-1 py-0.5 bg-primary/10 rounded-full">
v{newRelease.tag_name.replace(/^v/, "")}
</span>
)}
</div>
<Link
href={newRelease.html_url}
@@ -19,8 +19,6 @@ export const useUpdateCheck = () => {
staleTime: 1000 * 60 * 60,
});
console.log("newRelease", newRelease);
useEffect(() => {
if (!newRelease) return;
+63 -41
View File
@@ -14,7 +14,7 @@ type ParsedVersion = {
};
function parseVersion(version: string): ParsedVersion {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
if (!match) return {
major: 0,
minor: 0,
@@ -30,6 +30,49 @@ function parseVersion(version: string): ParsedVersion {
};
}
const findLatestVersion = (currentVersion: string, releases: GitHubRelease[]): GitHubRelease | null => {
const cleanCurrentVersion = currentVersion.replace(/^v/, "");
const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion);
const currentParsedVersion = parseVersion(cleanCurrentVersion);
if (isRc) {
const latestStableVersion = releases.find(r => !r.prerelease);
const latestRcVersion = releases.find(r => r.prerelease && (r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))));
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch >= currentParsedVersion.patch) return latestStableVersion;
}
if (latestRcVersion) {
const versionStr = latestRcVersion.tag_name || latestRcVersion.name;
const latestRcParsedVersion = parseVersion(versionStr);
if (latestRcParsedVersion.major > currentParsedVersion.major) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor > currentParsedVersion.minor) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch > currentParsedVersion.patch) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch === currentParsedVersion.patch &&
latestRcParsedVersion.rc !== undefined && currentParsedVersion.rc !== undefined && latestRcParsedVersion.rc > currentParsedVersion.rc) return latestRcVersion;
}
} else if (isStable) {
const latestStableVersion = releases.find(r => !r.prerelease);
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch > currentParsedVersion.patch) return latestStableVersion;
}
}
return null;
};
export const getNewRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try {
const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases");
@@ -38,49 +81,28 @@ export const getNewRelease = async (currentVersion: string): Promise<GitHubRelea
}
const releases: GitHubRelease[] = await response.json();
const isStable = /^\d+\.\d+\.\d+$/.test(currentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(currentVersion);
console.log("isStable", isStable)
console.log("isRc", isRc)
const currentParsedVersion = parseVersion(currentVersion)
const latestStableVersion = releases.find(r => !r.prerelease)
const latestRcVersion = releases.find(r => r.name.includes("rc"))!
if (isRc) {
if (!latestRcVersion) return null
const latestRcParsedVersion = parseVersion(latestRcVersion.name)
if (currentParsedVersion.major < latestRcParsedVersion.major) return latestRcVersion;
if (currentParsedVersion.minor < latestRcParsedVersion.minor) return latestRcVersion;
if (currentParsedVersion.patch < latestRcParsedVersion.patch) return latestRcVersion;
if (currentParsedVersion.rc! < latestRcParsedVersion.rc!) return latestRcVersion;
if (!latestStableVersion) return null
const latestStableParsedVersion = parseVersion(latestStableVersion.name)
if (currentParsedVersion.major < latestStableParsedVersion.major) return latestStableVersion;
if (currentParsedVersion.minor < latestStableParsedVersion.minor) return latestStableVersion;
if (currentParsedVersion.patch < latestStableParsedVersion.patch) return latestStableVersion;
return null;
} else if (isStable) {
if (!latestStableVersion) return null
const latestStableParsedVersion = parseVersion(latestStableVersion.name)
if (currentParsedVersion.major < latestStableParsedVersion.major) return latestStableVersion;
if (currentParsedVersion.minor < latestStableParsedVersion.minor) return latestStableVersion;
if (currentParsedVersion.patch < latestStableParsedVersion.patch) return latestStableVersion;
return null;
}
return null;
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest release", error);
return null;
}
};
export const getNewAgentRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try {
const response = await fetch("https://api.github.com/repos/Portabase/agent-rust/releases", {
next: { revalidate: 3600 }
});
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest agent release", error);
return null;
}
};
@@ -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"};
}
}