mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6952ac5f48 | ||
|
|
45acc781f2 | ||
|
|
9c2359e87b | ||
|
|
8dcb406790 | ||
|
|
a3d0a6e1df | ||
|
|
8edf0ca742 | ||
|
|
568d9612c5 | ||
|
|
6548945b2f | ||
|
|
4b1d0aba74 | ||
|
|
c726346258 | ||
|
|
0677f14ac5 | ||
|
|
d346f5cdc4 | ||
|
|
1ce08448ec | ||
|
|
77fb1095f1 | ||
|
|
29dd597c7a | ||
|
|
7943f3fd4b | ||
|
|
a63c29b943 | ||
|
|
f4d72090a3 | ||
|
|
8b379067a1 | ||
|
|
896b65f2a5 | ||
|
|
79d725cd12 |
@@ -73,7 +73,7 @@ jobs:
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
|
||||
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.20.3
|
||||
version: 1.21.3
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
@@ -111,4 +112,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Agent} from "@/db/schema/08_agent";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db, db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {and, eq, inArray, desc, sql} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
@@ -123,7 +123,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
)
|
||||
),
|
||||
orderBy: [
|
||||
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
|
||||
desc(drizzleDb.schemas.backup.createdAt)
|
||||
]
|
||||
})
|
||||
|
||||
const restoration = await dbClient.query.restoration.findFirst({
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/tus/hooks"});
|
||||
|
||||
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
|
||||
event.Upload.Offset === event.Upload.Size
|
||||
) {
|
||||
const id = event.Upload.ID;
|
||||
const fileName = headers["X-File-Name"]?.[0];
|
||||
const filePath = headers["X-File-Path"]?.[0];
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
if (!filePath || !FILE_PATH_RE.test(filePath)) {
|
||||
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
const generatedId = headers["X-Generated-Id"]?.[0];
|
||||
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
|
||||
|
||||
|
||||
console.log(`Backup Storage ID : ${backupStorageId}`);
|
||||
console.log(`generatedId : ${generatedId}`);
|
||||
|
||||
if (backupStorageId) {
|
||||
if (!generatedId) {
|
||||
log.warn("Missing X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const storage = await db.query.backupStorage.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||
),
|
||||
with: {backup: true},
|
||||
});
|
||||
|
||||
if (!storage || storage.backup?.databaseId !== database.id) {
|
||||
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
} else {
|
||||
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
|
||||
}
|
||||
|
||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
|
||||
const resolvedBase = path.resolve(uploadDir);
|
||||
const newFilePath = path.resolve(resolvedBase, filePath);
|
||||
const rel = path.relative(resolvedBase, newFilePath);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
log.warn({filePath}, "Rejected path traversal in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||
|
||||
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
log.error({error: error},"TUS Hook error");
|
||||
log.error({error: error}, "TUS Hook error");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -14,7 +14,12 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
storageKey="portabase_theme"
|
||||
>
|
||||
<ThemeMetaUpdater />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: portabase/portabase:1
|
||||
ports:
|
||||
- "8887:80"
|
||||
environment:
|
||||
|
||||
@@ -99,12 +99,58 @@ services:
|
||||
# clientEmail: "fake@test.iam.gserviceaccount.com",
|
||||
# privateKey: "unused-when-emulated",
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: rustfs
|
||||
environment:
|
||||
- RUSTFS_ROOT_USER=admin
|
||||
- RUSTFS_ROOT_PASSWORD=root-password
|
||||
- RUSTFS_ACCESS_KEY=access-key
|
||||
- RUSTFS_SECRET_KEY=secret-key
|
||||
- RUSTFS_VOLUMES=/data
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CONSOLE_ADDRESS=:9001
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "9001:9001"
|
||||
- "9000:9000"
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
rustfs-init:
|
||||
image: rustfs/rc:latest
|
||||
container_name: rustfs-init
|
||||
depends_on:
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
|
||||
/usr/bin/rc mb rustfs/portabase;
|
||||
echo 'RustFS initialization complete';
|
||||
tail -f /dev/null
|
||||
"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
azurite-data:
|
||||
gcs-data:
|
||||
gcs-storage:
|
||||
rustfs-data:
|
||||
|
||||
networks:
|
||||
portabase:
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
bash \
|
||||
tzdata \
|
||||
libc6 \
|
||||
nginx \
|
||||
gnupg \
|
||||
&& 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 bookworm-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM runtime-base AS build-base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
g++ \
|
||||
make \
|
||||
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
|
||||
automake \
|
||||
libtool \
|
||||
git \
|
||||
nginx \
|
||||
&& 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
|
||||
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
|
||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||
|
||||
|
||||
FROM base AS build-env
|
||||
FROM build-base AS build-env
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
@@ -83,7 +89,7 @@ COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS prod
|
||||
FROM runtime-base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
COPY --chown=1001:1001 src/db ./src/db
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||
RUN chmod +x /usr/local/bin/tusd
|
||||
|
||||
@@ -30,6 +30,11 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location = /api/tus/hooks {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
|
||||
+5
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.20.3",
|
||||
"version": "1.21.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -27,52 +27,38 @@
|
||||
"@onboardjs/react": "1.0.0-rc.5",
|
||||
"@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.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.11",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dockerode": "^4.0.12",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
@@ -82,33 +68,22 @@
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"npm-check-updates": "^18.3.1",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-qr-code": "^2.0.21",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.4",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uuid": "^11.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -119,29 +94,21 @@
|
||||
"@release-it/conventional-changelog": "^10.0.6",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.27.7",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.22.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||
}
|
||||
|
||||
Generated
+61
-2520
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,13 @@
|
||||
"when": 1782575192480,
|
||||
"tag": "0068_busy_zeigeist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1782937885439,
|
||||
"tag": "0069_worried_alex_wilder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
|
||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export const env = createEnv({
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
STALE_BACKUP_THRESHOLD_HOURS: z.coerce.number().default(6),
|
||||
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
@@ -147,6 +148,7 @@ export const env = createEnv({
|
||||
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||
STALE_BACKUP_THRESHOLD_HOURS: process.env.STALE_BACKUP_THRESHOLD_HOURS,
|
||||
|
||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||
|
||||
@@ -105,7 +105,7 @@ export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,6 +2,7 @@ export const STEP_IDS = {
|
||||
LOGIN: "login",
|
||||
ACCOUNT_INFO: "account-info",
|
||||
SECURITY: "security",
|
||||
PREFERENCES: "preferences",
|
||||
ORG_CREATE: "org-create",
|
||||
//INVITE_MEMBERS: "invite-members",
|
||||
NOTIFIER: "notifier",
|
||||
@@ -19,6 +20,7 @@ export const STEP_ORDER: string[] = [
|
||||
STEP_IDS.LOGIN,
|
||||
STEP_IDS.ACCOUNT_INFO,
|
||||
STEP_IDS.SECURITY,
|
||||
STEP_IDS.PREFERENCES,
|
||||
STEP_IDS.ORG_CREATE,
|
||||
//STEP_IDS.INVITE_MEMBERS,
|
||||
STEP_IDS.NOTIFIER,
|
||||
|
||||
@@ -61,9 +61,9 @@ export const OnboardingChecklist = () => {
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm pb-4",
|
||||
"text-sm pb-4 ",
|
||||
isCompleted && "text-zinc-400",
|
||||
isCurrent && "text-white font-medium",
|
||||
isCurrent && "text-primary font-medium",
|
||||
!isCompleted && !isCurrent && "text-zinc-600",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -84,8 +84,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
|
||||
const org = await getUserOwnOrganization(user.id);
|
||||
if (!org) {
|
||||
meta.resumeStepId = "org-create";
|
||||
return { stepId: "org-create", flowData: { meta } };
|
||||
meta.resumeStepId = "preferences";
|
||||
return { stepId: "preferences", flowData: { meta } };
|
||||
}
|
||||
|
||||
const orgData = { id: org.id, name: org.name };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OnboardingStep } from "@onboardjs/react";
|
||||
import { StepLogin } from "@/features/onboarding/steps/step-login";
|
||||
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
|
||||
import { StepSecurity } from "@/features/onboarding/steps/step-security";
|
||||
import { StepPreferences } from "@/features/onboarding/steps/step-preferences";
|
||||
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
|
||||
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
|
||||
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
|
||||
@@ -20,19 +21,26 @@ export const onboardingSteps: OnboardingStep[] = [
|
||||
component: StepLogin,
|
||||
isSkippable: false,
|
||||
nextStep: (ctx: any) =>
|
||||
ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info",
|
||||
ctx.flowData?.meta?.hasExistingUsers ? "preferences" : "account-info",
|
||||
},
|
||||
{
|
||||
id: "account-info",
|
||||
component: StepAccountInfo,
|
||||
isSkippable: false,
|
||||
nextStep: (ctx: any) =>
|
||||
ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security",
|
||||
ctx.flowData?.meta?.passkeyEnabled ? "preferences" : "security",
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
component: StepSecurity,
|
||||
isSkippable: true,
|
||||
skipToStep: "preferences",
|
||||
nextStep: "preferences",
|
||||
},
|
||||
{
|
||||
id: "preferences",
|
||||
component: StepPreferences,
|
||||
isSkippable: true,
|
||||
skipToStep: "org-create",
|
||||
nextStep: "org-create",
|
||||
},
|
||||
|
||||
@@ -70,7 +70,16 @@ export const StepAgentCreate = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={() => next()}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => next()}
|
||||
disabled={!!name.trim() || createAgent.isPending}
|
||||
title={
|
||||
name.trim()
|
||||
? "Add or clear the agent name before continuing"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ export const StepDefaults = () => {
|
||||
const [storageId, setStorageId] = useState<string | undefined>(
|
||||
existingDefaults.storageId || undefined,
|
||||
);
|
||||
const [notifierOpen, setNotifierOpen] = useState(false);
|
||||
const [storageOpen, setStorageOpen] = useState(false);
|
||||
|
||||
const selectNotifier = async (value: string) => {
|
||||
const prev = notifierId;
|
||||
@@ -56,6 +58,26 @@ export const StepDefaults = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearNotifier = async () => {
|
||||
const prev = notifierId;
|
||||
setNotifierId(undefined);
|
||||
try {
|
||||
await updateNotificationSettingsAction({
|
||||
name: "system",
|
||||
data: { notificationChannelId: null },
|
||||
});
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId: undefined, storageId },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setNotifierId(prev);
|
||||
toast.error("Failed to clear default notifier");
|
||||
}
|
||||
};
|
||||
|
||||
const selectStorage = async (value: string) => {
|
||||
const prev = storageId;
|
||||
setStorageId(value);
|
||||
@@ -76,6 +98,26 @@ export const StepDefaults = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearStorage = async () => {
|
||||
const prev = storageId;
|
||||
setStorageId(undefined);
|
||||
try {
|
||||
await updateStorageSettingsAction({
|
||||
name: "system",
|
||||
data: { storageChannelId: null, encryption: false },
|
||||
});
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId, storageId: undefined },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setStorageId(prev);
|
||||
toast.error("Failed to clear default storage");
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({
|
||||
flowData: {
|
||||
@@ -102,8 +144,10 @@ export const StepDefaults = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default notifier</Label>
|
||||
<Select
|
||||
value={selectedNotifier ? notifierId : undefined}
|
||||
value={selectedNotifier ? notifierId : ""}
|
||||
onValueChange={selectNotifier}
|
||||
open={notifierOpen}
|
||||
onOpenChange={setNotifierOpen}
|
||||
disabled={notifiers.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -128,7 +172,17 @@ export const StepDefaults = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notifiers.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
<SelectItem
|
||||
key={n.id}
|
||||
value={n.id}
|
||||
onPointerUp={(e) => {
|
||||
if (n.id === notifierId) {
|
||||
e.preventDefault();
|
||||
clearNotifier();
|
||||
setNotifierOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<div className="text-muted-foreground scale-90 shrink-0">
|
||||
{getChannelIcon(n.provider)}
|
||||
@@ -149,8 +203,10 @@ export const StepDefaults = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default storage</Label>
|
||||
<Select
|
||||
value={selectedStorage ? storageId : undefined}
|
||||
value={selectedStorage ? storageId : ""}
|
||||
onValueChange={selectStorage}
|
||||
open={storageOpen}
|
||||
onOpenChange={setStorageOpen}
|
||||
disabled={storages.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -175,7 +231,17 @@ export const StepDefaults = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storages.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
onPointerUp={(e) => {
|
||||
if (s.id === storageId) {
|
||||
e.preventDefault();
|
||||
clearStorage();
|
||||
setStorageOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<div className="text-muted-foreground scale-90 shrink-0">
|
||||
{getChannelIcon(s.provider)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
|
||||
|
||||
export const StepPreferences = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const { theme: currentTheme, setTheme } = useTheme();
|
||||
const preferences = state?.context.flowData.preferences ?? {
|
||||
theme: (currentTheme ?? "system") as ThemeKey,
|
||||
};
|
||||
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
setTheme(theme);
|
||||
try {
|
||||
await authClient.updateUser({ theme });
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
preferences: { ...preferences, theme },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to save theme preference");
|
||||
setTheme(preferences.theme as ThemeKey);
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Your preferences</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Optional — personalise your workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Theme</p>
|
||||
<ThemeSelector value={preferences.theme} onSelect={selectTheme} />
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const updateStorageSettingsAction = userAction
|
||||
const [updatedSettings] = await db
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(withUpdatedAt({
|
||||
defaultStorageChannelId: data.storageChannelId,
|
||||
defaultStorageChannelId: data.storageChannelId ?? null,
|
||||
encryption: data.encryption,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.setting.name, name))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DefaultStorageSchema = z.object({
|
||||
storageChannelId: z.string(),
|
||||
storageChannelId: z.string().optional().nullable(),
|
||||
encryption: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {db} from "@/db";
|
||||
import {and, eq, isNotNull, isNull} from "drizzle-orm";
|
||||
import {and, eq, isNotNull, isNull, lt} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {env} from "@/env.mjs";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
|
||||
|
||||
const log = logger.child({module: "tasks/cleaning"});
|
||||
|
||||
@@ -23,6 +25,36 @@ export const backupCleanTask = async () => {
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
}
|
||||
|
||||
const staleCutoff = new Date(Date.now() - env.STALE_BACKUP_THRESHOLD_HOURS * 60 * 60 * 1000);
|
||||
|
||||
const staleOngoingBackups = await db.query.backup.findMany({
|
||||
where: and(
|
||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
eq(drizzleDb.schemas.backup.status, "ongoing"),
|
||||
lt(drizzleDb.schemas.backup.createdAt, staleCutoff)
|
||||
)
|
||||
});
|
||||
log.debug(`Stale ongoing backups to fail: ${staleOngoingBackups.length}`);
|
||||
|
||||
for (const backup of staleOngoingBackups) {
|
||||
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
|
||||
status: "failed",
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||
|
||||
try {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, backup.databaseId),
|
||||
with: {alertPolicies: true},
|
||||
});
|
||||
if (database) {
|
||||
await sendNotificationsBackupRestore(database, "error_backup");
|
||||
}
|
||||
} catch (notifyError) {
|
||||
log.error({name: "backupCleanTask", error: notifyError}, "Stale backup notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
const failedBackups = await db.query.backup.findMany({
|
||||
where: and(
|
||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ export function capitalizeFirstLetter(text: string): string {
|
||||
}
|
||||
|
||||
export function isUUID(str: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
str,
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
str,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user