mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fadfcfaf2 | ||
|
|
af3df7f72a | ||
|
|
2cd5bda416 | ||
|
|
965b215224 | ||
|
|
27f6b746e0 | ||
|
|
7d463d7da8 | ||
|
|
332540488d | ||
|
|
ab52ef6c27 | ||
|
|
2d55618898 | ||
|
|
6b3df405eb | ||
|
|
e91ef4c142 | ||
|
|
9b504ccd68 | ||
|
|
dd3f4411cf | ||
|
|
0ab94b6221 | ||
|
|
8477889cea | ||
|
|
2eb4a7a0fe | ||
|
|
1f68adda90 | ||
|
|
ffba2c56a3 | ||
|
|
02be78a26e | ||
|
|
711d4613ed | ||
|
|
a5fe5225e1 | ||
|
|
b752d9437c | ||
|
|
18df92e048 | ||
|
|
4e66b8ed04 | ||
|
|
4f14d16452 | ||
|
|
592588ef76 | ||
|
|
3fec7c3392 | ||
|
|
2335caefd7 | ||
|
|
194eedeaa8 | ||
|
|
ecd09aedb9 | ||
|
|
b5350b5fe3 | ||
|
|
ceaafd5531 | ||
|
|
654accad4a | ||
|
|
173c7894b4 | ||
|
|
1ab51d15f9 | ||
|
|
ddce51d2d3 | ||
|
|
fc9fbf4357 | ||
|
|
5e8cd7e0e6 | ||
|
|
1eb5fbb983 |
@@ -1,6 +1,12 @@
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Logging
|
||||
# Controls minimum log level. Options: debug | info | warn | error
|
||||
# Set to "warn" or "error" in production to suppress health-check ping noise.
|
||||
# Use "debug" to see all request logs including agent status pings.
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
@@ -9,6 +15,15 @@ PROJECT_NAME="Portabase"
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
AUTH_DEFAULT_USER_NAME="Portabase Admin"
|
||||
AUTH_DEFAULT_USER=user@example.com
|
||||
# Password must contain at least 8 characters
|
||||
# Password must contain at least 1 number
|
||||
# Password must contain at least 1 lowercase letter
|
||||
# Password must contain at least 1 uppercase letter
|
||||
# Password must contain at least 1 special character
|
||||
AUTH_DEFAULT_PASSWORD=testPASS123456!
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
|
||||
@@ -55,3 +55,7 @@ certificates
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/e2e/local-storage.json
|
||||
|
||||
.claude
|
||||
.codex
|
||||
/docs
|
||||
|
||||
+3
-1
@@ -21,6 +21,8 @@ keywords:
|
||||
- backups
|
||||
- postgresql
|
||||
- mysql
|
||||
- firebird
|
||||
- mssql
|
||||
- mariadb
|
||||
- devops
|
||||
- database
|
||||
@@ -31,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.13.1
|
||||
version: 1.15.4
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://valkey.io/)
|
||||
[](https://firebirdsql.org/)
|
||||
[](https://www.microsoft.com/en-us/sql-server)
|
||||
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
@@ -65,17 +66,17 @@ You have 4 ways to install Portabase:
|
||||
|
||||
## Supported databases
|
||||
|
||||
| Engine | Support | Supported Versions | Restore |
|
||||
|:-------------------|:-----------|:------------------------------|:--------|
|
||||
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
|
||||
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
|
||||
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
|
||||
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
|
||||
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ❌ Ongoing | - | Yes |
|
||||
| Engine | Support | Supported Versions | Restore |
|
||||
|:-------------------|:----------|:------------------------------|:--------|
|
||||
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
|
||||
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
|
||||
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
|
||||
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
|
||||
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {notFound} from "next/navigation";
|
||||
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
@@ -8,13 +9,15 @@ import * as drizzleDb from "@/db";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
|
||||
const log = logger.child({module: "dashboard/admin/settings"});
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||
});
|
||||
|
||||
console.log(settings)
|
||||
log.debug({settings}, "Settings loaded");
|
||||
|
||||
const storageChannels = await db.query.storageChannel.findMany({
|
||||
with: {
|
||||
@@ -33,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
}) as NotificationChannelWith[]
|
||||
|
||||
|
||||
if (!settings || !storageChannels || !notificationChannels ) {
|
||||
if (!settings || !storageChannels || !notificationChannels) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -45,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
|
||||
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
|
||||
settings={settings}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -30,9 +30,11 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader className="flex flex-col">
|
||||
<div className="flex justify-between">
|
||||
<PageTitle className="mb-3">Active users</PageTitle>
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
{isPasswordAuthEnabled && (
|
||||
<PageActions>
|
||||
<AdminUserAddModal organizations={organizations}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
|
||||
@@ -9,8 +9,4 @@ export default async function Index() {
|
||||
redirect(`/dashboard/home`);
|
||||
}
|
||||
redirect("/login");
|
||||
//Do not delete
|
||||
// return (
|
||||
// <Home/>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function POST(
|
||||
) {
|
||||
try {
|
||||
const agentId = (await params).agentId
|
||||
log.info(`Agent ID: ${agentId}`)
|
||||
log.debug(`Agent ID: ${agentId}`)
|
||||
const body: Body = await request.json();
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
+11
-5
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1.7.1
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
@@ -17,6 +17,12 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
container_name: portabase-app-prod
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
+10
-1
@@ -12,7 +12,7 @@ services:
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -32,5 +32,14 @@ services:
|
||||
- ./private/uploads/tmp:/data/uploads/tmp
|
||||
user: "1000:1000"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: portabase-mailpit-dev
|
||||
hostname: mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
@@ -31,8 +31,9 @@ const invalidChannelName = "Webhook E2E Invalid";
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// await page.getByRole("button", { name: "Add Header" }).click();
|
||||
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
// await page.getByLabel(/^Secret Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// await page.getByLabel(/^Header Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
@@ -49,8 +50,9 @@ test.describe.serial("Invalid channel", () => {
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Webhook", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
|
||||
await page.getByRole("button", { name: "Add Header" }).click();
|
||||
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
await page.getByLabel(/^Secret Value$/).fill("wrong-webhook-secret");
|
||||
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
|
||||
@@ -27,6 +27,30 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 80
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 12
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 80
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 80
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.13.1",
|
||||
"version": "1.15.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -16,8 +16,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/core": "1.6.2",
|
||||
"@better-auth/passkey": "^1.6.2",
|
||||
"@better-auth/sso": "^1.6.2",
|
||||
"@better-auth/passkey": "1.6.2",
|
||||
"@better-auth/sso": "1.6.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -128,7 +128,7 @@
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"postcss": "^8.5.9",
|
||||
"postcss": "8.5.10",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -136,5 +136,5 @@
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
"packageManager": "pnpm@11.1.3"
|
||||
}
|
||||
|
||||
Generated
+2038
-1547
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,17 @@
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
argon2: true
|
||||
bcrypt: true
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
node-pty: true
|
||||
prisma: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
ssh2: true
|
||||
unrs-resolver: true
|
||||
zenstack: true
|
||||
ignoredBuiltDependencies:
|
||||
- node-pty
|
||||
onlyBuiltDependencies:
|
||||
|
||||
@@ -71,6 +71,7 @@ function checkRouteExists(pathname: string) {
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/health\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
+5
@@ -6,6 +6,7 @@ import {TelegramChannelConfigSchema} from "./providers/notifications/forms/teleg
|
||||
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
|
||||
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
|
||||
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
|
||||
import {NextcloudChannelConfigSchema} from "./providers/notifications/forms/nextcloud.schema";
|
||||
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
|
||||
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
|
||||
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
|
||||
@@ -48,6 +49,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("webhook"),
|
||||
config: WebhookChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("nextcloud"),
|
||||
config: NextcloudChannelConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNextcloudFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nextcloud URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://cloud.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. j3yujpuh" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="HMAC signing secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NextcloudChannelConfigSchema = z.object({
|
||||
nextcloudUrl: z.string().url("Must be a valid URL"),
|
||||
nextcloudBotToken: z.string().min(1, "Bot token is required"),
|
||||
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
|
||||
});
|
||||
+112
-62
@@ -1,72 +1,122 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
"use client";
|
||||
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://example.com/api/webhook" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. X-Webhook-Secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. webhook-secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults
|
||||
to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "config.webhookHeaders",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. https://example.com/api/webhook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Custom Headers</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom headers. Add headers to send with each webhook request.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((headerField, index) => (
|
||||
<div key={headerField.id} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. X-Api-Key"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-3
@@ -1,7 +1,16 @@
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url("Must be a valid URL"),
|
||||
webhookSecretHeader: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookHeaders: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Header name is required"),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/nextcloud.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
@@ -87,6 +90,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
||||
return <NotifierNtfyForm form={form}/>;
|
||||
case "webhook":
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "nextcloud":
|
||||
return <NotifierNextcloudForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
|
||||
@@ -13,6 +13,7 @@ export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
]
|
||||
|
||||
@@ -530,3 +531,28 @@ export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M512 80.5v352c0 44.2-35.8 80-80 80H80c-44.2 0-80-35.8-80-80v-352C0 36.3 35.8.5 80 .5h352c44.2 0 80 35.8 80 80"
|
||||
style={{
|
||||
fillRule: "evenodd",
|
||||
clipRule: "evenodd",
|
||||
fill: "#0082c9",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M256 128.5c-70.4 0-127.5 57.1-127.5 127.5S185.6 383.5 256 383.5c23.3 0 46.2-6.5 66.1-18.6 15.7 6.2 50.8 24.7 59.1 16.7 8.7-8.3-10.2-47.6-14.8-62.1 11.1-19.3 17-41.1 17-63.4.1-70.5-57-127.6-127.4-127.6m0 48.5c43.7 0 79 35.4 79 79 0 43.7-35.4 79-79 79s-79-35.4-79-79 35.3-79 79-79"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const createUserAction = userAction
|
||||
email: parsedInput.email,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
password: ""
|
||||
};
|
||||
|
||||
if (isPasswordAuthEnabled) {
|
||||
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {eq, inArray} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
const log = logger.child({ module: "dashboard/delete-project.action" });
|
||||
|
||||
export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
|
||||
try {
|
||||
const uuid = uuidv4();
|
||||
@@ -51,7 +54,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
log.error({ error }, "Failed to archive project");
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'mssql';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "backups" ALTER COLUMN "file_size" SET DATA TYPE bigint;--> statement-breakpoint
|
||||
ALTER TABLE "backup_storage" ALTER COLUMN "size" SET DATA TYPE bigint;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_kind" ADD VALUE 'nextcloud';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -379,6 +379,27 @@
|
||||
"when": 1777204042208,
|
||||
"tag": "0053_lyrical_union_jack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1778523056918,
|
||||
"tag": "0054_hesitant_darkhawk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 55,
|
||||
"version": "7",
|
||||
"when": 1779342014802,
|
||||
"tag": "0055_yielding_justin_hammer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1779380470656,
|
||||
"tag": "0056_lazy_cyclops",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core";
|
||||
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum, bigint} from "drizzle-orm/pg-core";
|
||||
import {Agent, agent, AgentWith} from "./08_agent";
|
||||
import {Project, project} from "./06_project";
|
||||
import {relations} from "drizzle-orm";
|
||||
@@ -37,7 +37,7 @@ export const backup = pgTable(
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
file: text("file"),
|
||||
fileSize: integer("file_size"),
|
||||
fileSize: bigint("file_size", { mode: "number" }),
|
||||
databaseId: uuid("database_id")
|
||||
.notNull()
|
||||
.references(() => database.id, {onDelete: "cascade"}),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook']);
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, integer, pgEnum } from "drizzle-orm/pg-core";
|
||||
import {pgTable, uuid, text, integer, pgEnum, bigint} from "drizzle-orm/pg-core";
|
||||
import { timestamps } from "@/db/schema/00_common";
|
||||
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {Backup, backup, Restoration} from "@/db/schema/07_database";
|
||||
@@ -22,7 +22,7 @@ export const backupStorage = pgTable("backup_storage", {
|
||||
.references(() => storageChannel.id, { onDelete: "cascade" }),
|
||||
status: backupStorageStatusEnum("status").notNull().default("pending"),
|
||||
path: text("path"),
|
||||
size: integer("size"),
|
||||
size: bigint("size", { mode: "number" }),
|
||||
checksum: text("checksum"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -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", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird"]);
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
|
||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import {hashPassword} from "better-auth/crypto";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {User, UserThemeEnum} from "@/db/schema/02_user";
|
||||
import {assertValidPassword} from "@/utils/password";
|
||||
|
||||
|
||||
export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
assertValidPassword(data.password);
|
||||
|
||||
const now = new Date();
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
|
||||
...data,
|
||||
id: userId,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
|
||||
+134
-125
@@ -1,165 +1,174 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import {createEnv} from "@t3-oss/env-nextjs";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import {z} from "zod";
|
||||
import packageJson from "../package.json" with {type: "json"};
|
||||
|
||||
const { version } = packageJson;
|
||||
const {version} = packageJson;
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
|
||||
NODE_ENV: z.enum(["development", "production"]).optional(),
|
||||
NODE_ENV: z.enum(["development", "production"]).optional(),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
|
||||
DATABASE_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.url().optional(),
|
||||
|
||||
PROJECT_NAME: z.string().optional(),
|
||||
PROJECT_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z
|
||||
.string()
|
||||
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
PROJECT_NAME: z.string().optional(),
|
||||
PROJECT_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z
|
||||
.string()
|
||||
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
|
||||
TRUSTED_DOMAINS: z.string().optional(),
|
||||
TRUSTED_DOMAINS: z.string().optional(),
|
||||
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
|
||||
AUTH_DEFAULT_USER_NAME: z.string().optional(),
|
||||
AUTH_DEFAULT_USER: z.string().optional(),
|
||||
AUTH_DEFAULT_PASSWORD: z.string().optional(),
|
||||
|
||||
|
||||
SMTP_SECURE: z
|
||||
.enum(["true", "false"])
|
||||
.transform((val) => val === "true")
|
||||
.default("true"),
|
||||
SMTP_SECURE: z
|
||||
.enum(["true", "false"])
|
||||
.transform((val) => val === "true")
|
||||
.default("true"),
|
||||
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
|
||||
RETENTION_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
|
||||
),
|
||||
RETENTION_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
HEALTHCHECK_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
HEALTHCHECK_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
AUTH_OIDC_DESC: z.string().optional(),
|
||||
AUTH_OIDC_ICON: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT: z.string().optional(),
|
||||
AUTH_OIDC_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_ISSUER_URL: z.string().optional(),
|
||||
AUTH_OIDC_HOST: z.string().optional(),
|
||||
AUTH_OIDC_SCOPES: z.string().optional(),
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_PKCE: z.string().optional(),
|
||||
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
AUTH_OIDC_DESC: z.string().optional(),
|
||||
AUTH_OIDC_ICON: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT: z.string().optional(),
|
||||
AUTH_OIDC_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_ISSUER_URL: z.string().optional(),
|
||||
AUTH_OIDC_HOST: z.string().optional(),
|
||||
AUTH_OIDC_SCOPES: z.string().optional(),
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_PKCE: z.string().optional(),
|
||||
AUTH_SOCIAL_ID: z.string().optional().default("social"),
|
||||
AUTH_SOCIAL_TITLE: z.string().optional(),
|
||||
AUTH_SOCIAL_DESC: z.string().optional(),
|
||||
AUTH_SOCIAL_ICON: z.string().optional(),
|
||||
AUTH_SOCIAL_CLIENT: z.string().optional(),
|
||||
AUTH_SOCIAL_SECRET: z.string().optional(),
|
||||
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||
|
||||
AUTH_SOCIAL_ID: z.string().optional().default("social"),
|
||||
AUTH_SOCIAL_TITLE: z.string().optional(),
|
||||
AUTH_SOCIAL_DESC: z.string().optional(),
|
||||
AUTH_SOCIAL_ICON: z.string().optional(),
|
||||
AUTH_SOCIAL_CLIENT: z.string().optional(),
|
||||
AUTH_SOCIAL_SECRET: z.string().optional(),
|
||||
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ROLE_MAP: z.string().optional(),
|
||||
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
|
||||
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
||||
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ROLE_MAP: z.string().optional(),
|
||||
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
|
||||
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
||||
PRIVATE_PATH: z.string().optional(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
|
||||
PRIVATE_PATH: z.string().optional(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
TRUSTED_DOMAINS: process.env.TRUSTED_DOMAINS,
|
||||
|
||||
TRUSTED_DOMAINS: process.env.TRUSTED_DOMAINS,
|
||||
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
|
||||
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
|
||||
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
|
||||
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
|
||||
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
|
||||
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
|
||||
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
|
||||
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
|
||||
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
|
||||
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
|
||||
|
||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
|
||||
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
|
||||
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
|
||||
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
|
||||
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
|
||||
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
|
||||
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
|
||||
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
|
||||
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
|
||||
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
|
||||
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
|
||||
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
|
||||
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
|
||||
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
|
||||
|
||||
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
|
||||
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
|
||||
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
|
||||
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
|
||||
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
|
||||
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
|
||||
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
|
||||
|
||||
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
|
||||
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
|
||||
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
|
||||
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
|
||||
AUTH_ROLE_MAP: process.env.AUTH_ROLE_MAP,
|
||||
|
||||
AUTH_ROLE_MAP: process.env.AUTH_ROLE_MAP,
|
||||
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
|
||||
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
|
||||
|
||||
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
|
||||
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
|
||||
PRIVATE_PATH:
|
||||
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
|
||||
|
||||
PRIVATE_PATH:
|
||||
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
|
||||
},
|
||||
AUTH_DEFAULT_USER_NAME: process.env.AUTH_DEFAULT_USER_NAME,
|
||||
AUTH_DEFAULT_USER: process.env.AUTH_DEFAULT_USER,
|
||||
AUTH_DEFAULT_PASSWORD: process.env.AUTH_DEFAULT_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {sendTelegram} from "@/features/notifications/providers/telegram";
|
||||
import {sendGotify} from "@/features/notifications/providers/gotify";
|
||||
import {sendNtfy} from "@/features/notifications/providers/ntfy";
|
||||
import {sendWebhook} from "@/features/notifications/providers/webhook";
|
||||
import {sendNextcloud} from "@/features/notifications/providers/nextcloud";
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
@@ -18,7 +19,8 @@ const handlers: Record<
|
||||
telegram: sendTelegram,
|
||||
gotify: sendGotify,
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import type { EventPayload, DispatchResult } from "../types";
|
||||
|
||||
type NextcloudConfig = {
|
||||
nextcloudUrl: string;
|
||||
nextcloudBotToken: string;
|
||||
nextcloudBotSecret: string;
|
||||
};
|
||||
|
||||
function formatPayloadData(data: unknown): string {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNextcloud(
|
||||
config: NextcloudConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {
|
||||
nextcloudUrl,
|
||||
nextcloudBotToken,
|
||||
nextcloudBotSecret,
|
||||
} = config;
|
||||
|
||||
const payloadData = formatPayloadData(payload.data);
|
||||
|
||||
const messageParts = [
|
||||
`[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
payload.message,
|
||||
];
|
||||
|
||||
if (payloadData) {
|
||||
messageParts.push(`Payload:\n${payloadData}`);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n\n");
|
||||
|
||||
const random = randomBytes(32).toString("hex");
|
||||
|
||||
const signature = createHmac("sha256", nextcloudBotSecret)
|
||||
.update(random + message)
|
||||
.digest("hex");
|
||||
|
||||
const baseUrl = nextcloudUrl.replace(/\/$/, "");
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
|
||||
throw new Error(
|
||||
`Nextcloud error: ${res.status} ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "nextcloud",
|
||||
message: "Sent to Nextcloud Talk",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,38 @@
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
import type { EventPayload, DispatchResult } from '../types';
|
||||
|
||||
type WebhookConfig = {
|
||||
webhookUrl: string;
|
||||
webhookHeaders?: { key: string; value: string }[];
|
||||
webhookSecret?: string;
|
||||
webhookSecretHeader?: string;
|
||||
};
|
||||
|
||||
export async function sendWebhook(
|
||||
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string },
|
||||
config: WebhookConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {webhookUrl, webhookSecret, webhookSecretHeader} = config;
|
||||
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Portabase-Notifier/1.0'
|
||||
'User-Agent': 'Portabase-Notifier/1.0',
|
||||
};
|
||||
|
||||
if (webhookSecret) {
|
||||
// New format: iterate custom headers array
|
||||
if (webhookHeaders && webhookHeaders.length > 0) {
|
||||
const RESERVED = new Set(['content-type', 'user-agent']);
|
||||
for (const { key, value } of webhookHeaders) {
|
||||
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
|
||||
}
|
||||
} else if (webhookSecret) {
|
||||
// Legacy format: single secret header pair
|
||||
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: headers,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook';
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud';
|
||||
|
||||
export interface DispatchResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -11,6 +11,9 @@ import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
StorageChannelFormType
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const log = logger.child({ module: "features/storages/dispatch" });
|
||||
|
||||
|
||||
export async function dispatchStorage(
|
||||
@@ -103,7 +106,7 @@ export async function dispatchStorage(
|
||||
input,
|
||||
);
|
||||
|
||||
console.log(dispatchResult);
|
||||
log.debug({ result: dispatchResult }, "Storage dispatch result");
|
||||
return dispatchResult;
|
||||
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {db} from "@/db";
|
||||
@@ -10,6 +11,7 @@ import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import sharp from "sharp";
|
||||
|
||||
const log = logger.child({module: "features/upload/upload.action"});
|
||||
|
||||
export const uploadUserImageAction = userAction.schema(
|
||||
z.instanceof(FormData)
|
||||
@@ -27,7 +29,7 @@ export const uploadUserImageAction = userAction.schema(
|
||||
const isWebp = fileFormat === "webp";
|
||||
|
||||
const compressedBuffer = await sharp(buffer)
|
||||
.resize({ width: 1024 })
|
||||
.resize({width: 1024})
|
||||
.toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", {
|
||||
quality: 80,
|
||||
compressionLevel: isPng ? 9 : undefined
|
||||
@@ -35,7 +37,6 @@ export const uploadUserImageAction = userAction.schema(
|
||||
.toBuffer();
|
||||
|
||||
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
with: {
|
||||
@@ -69,7 +70,7 @@ export const uploadUserImageAction = userAction.schema(
|
||||
}
|
||||
|
||||
const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
|
||||
console.log(result);
|
||||
log.debug({result}, "Upload dispatch result");
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -22,7 +22,9 @@ import {passkey} from "@better-auth/passkey";
|
||||
import {getOidcProviders} from "./oidc";
|
||||
import {APIError} from "better-auth/api";
|
||||
import {getOAuthProviders} from "./oauth";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const log = logger.child({ module: "lib/auth" });
|
||||
|
||||
const oidcProviders = getOidcProviders();
|
||||
|
||||
@@ -438,9 +440,7 @@ export const auth = betterAuth({
|
||||
new Date(user.createdAt).getTime();
|
||||
|
||||
if (createdAtDiff < 5000) {
|
||||
console.log(
|
||||
`Skipping new login email for freshly created user ${user.email}`,
|
||||
);
|
||||
log.debug(`Skipping new login email for freshly created user ${user.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -471,12 +471,12 @@ export const auth = betterAuth({
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("sendEmail failed", {
|
||||
log.error({
|
||||
userId: user.id,
|
||||
details: "Please Check your env variables or system config",
|
||||
email: user.email,
|
||||
error,
|
||||
});
|
||||
}, "sendEmail failed");
|
||||
}
|
||||
|
||||
(await auth.$context).internalAdapter.updateUser(user.id, {
|
||||
@@ -784,7 +784,7 @@ export const getActiveMember = async () => {
|
||||
|
||||
return member as MemberWithUser;
|
||||
} catch (e) {
|
||||
console.log("err", e);
|
||||
log.error({ error: e }, "Auth error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,8 @@
|
||||
import pino, { type Logger } from "pino";
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const defaultLevel = isProd ? "info" : "debug";
|
||||
const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level;
|
||||
|
||||
function getLocalTimestamp() {
|
||||
const date = new Date();
|
||||
@@ -19,9 +21,8 @@ function getLocalTimestamp() {
|
||||
}
|
||||
|
||||
export const logger: Logger = pino({
|
||||
level: isProd ? "info" : "debug",
|
||||
level: level,
|
||||
base: null,
|
||||
|
||||
...(isProd
|
||||
? {
|
||||
timestamp: getLocalTimestamp,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const backupCleanTask = async () => {
|
||||
eq(drizzleDb.schemas.backup.status, "ongoing")
|
||||
)
|
||||
});
|
||||
log.info(`Backups to clean: ${backups.length}`);
|
||||
log.debug(`Backups to clean: ${backups.length}`);
|
||||
|
||||
for (const backup of backups) {
|
||||
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
|
||||
@@ -24,7 +24,7 @@ export const backupCleanTask = async () => {
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
log.info({name: "backupCleanTask", error: e},`Backup cleanup failed`);
|
||||
log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
|
||||
|
||||
export const cleaningJob = cron.schedule("* * * * *", async () => {
|
||||
try {
|
||||
log.info({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
|
||||
log.debug({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
|
||||
await backupCleanTask();
|
||||
} catch (err) {
|
||||
log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error");
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
|
||||
type LogLevel = typeof LOG_LEVELS[number];
|
||||
|
||||
const raw = (process.env.LOG_LEVEL ?? "info") as LogLevel;
|
||||
const _idx = LOG_LEVELS.indexOf(raw);
|
||||
const LEVEL_INDEX = _idx === -1 ? 1 : _idx;
|
||||
|
||||
const STATUS_PATH_RE = /\/api\/agent\/[^/]+\/status\/?$/;
|
||||
|
||||
export function loggingMiddleware(request: NextRequest) {
|
||||
if (request.url.includes('/api')) {
|
||||
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
|
||||
const isStatusPing = STATUS_PATH_RE.test(request.nextUrl.pathname);
|
||||
// Status pings are debug-level; all other API calls are info-level.
|
||||
const requiredIndex = isStatusPing ? 0 : 1;
|
||||
if (LEVEL_INDEX <= requiredIndex) {
|
||||
console.log(`[API] Received ${request.method} request : ${request.url} at ${new Date()}`);
|
||||
}
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
|
||||
export type SignUpUser = {
|
||||
name: string
|
||||
email: string
|
||||
password?: string
|
||||
password: string
|
||||
callbackURL?: string
|
||||
role?: string
|
||||
theme: string
|
||||
emailVerified?: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string
|
||||
return {
|
||||
"application/octet-stream": [".backup"],
|
||||
};
|
||||
case "mssql":
|
||||
return {
|
||||
"application/octet-stream": [".bacpac"],
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported database type: ${dbType}`);
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { db, makeMigration } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob, retentionJob} from "@/lib/tasks";
|
||||
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
|
||||
import { StorageProviderKind } from "@/features/storages/types";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
const log = logger.child({module: "init"});
|
||||
|
||||
export async function init() {
|
||||
consoleAscii();
|
||||
|
||||
log.info("====Init Functions====");
|
||||
await getOrCreateMasterKey();
|
||||
await generateRSAKeys();
|
||||
await makeMigration();
|
||||
await createDefaultOrganization();
|
||||
await createSettingsIfNotExist();
|
||||
log.info("====Initialization completed====");
|
||||
await setupCronJobs();
|
||||
|
||||
if (
|
||||
(env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) ||
|
||||
(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET)
|
||||
) {
|
||||
log.warn(
|
||||
{
|
||||
deprecated: true,
|
||||
provider: "oauth_env",
|
||||
message: "You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)"
|
||||
},
|
||||
"Deprecated OAuth environment variables detected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupCronJobs() {
|
||||
|
||||
log.info("==== Setting up Cron Jobs ====");
|
||||
retentionJob.start();
|
||||
cleaningJob.start();
|
||||
cleaningHealthcheckLogsJob.start();
|
||||
healthcheckAgentAndDatabaseJob.start();
|
||||
log.info("==== Cron jobs started ====");
|
||||
}
|
||||
|
||||
async function createSettingsIfNotExist() {
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
const systemSettingsValues = {
|
||||
name: "system",
|
||||
smtpPassword: env.SMTP_PASSWORD ?? null,
|
||||
smtpFrom: env.SMTP_FROM ?? null,
|
||||
smtpHost: env.SMTP_HOST ?? null,
|
||||
smtpPort: env.SMTP_PORT ?? null,
|
||||
smtpUser: env.SMTP_USER ?? null,
|
||||
smtpSecure: env.SMTP_SECURE ?? false,
|
||||
};
|
||||
|
||||
const [systemSetting] = await tx
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
|
||||
const [finalSystemSetting] = systemSetting
|
||||
? await tx
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(systemSettingsValues)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(drizzleDb.schemas.setting)
|
||||
.values(systemSettingsValues)
|
||||
.returning();
|
||||
|
||||
const localStorageValues = {
|
||||
provider: "local" as StorageProviderKind,
|
||||
enabled: true,
|
||||
name: "System",
|
||||
config: {},
|
||||
};
|
||||
|
||||
const [existingLocalStorage] = await tx
|
||||
.select()
|
||||
.from(drizzleDb.schemas.storageChannel)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
|
||||
.limit(1);
|
||||
|
||||
const [localStorage] = existingLocalStorage
|
||||
? await tx
|
||||
.update(drizzleDb.schemas.storageChannel)
|
||||
.set(localStorageValues)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(drizzleDb.schemas.storageChannel)
|
||||
.values(localStorageValues)
|
||||
.returning();
|
||||
|
||||
if (!finalSystemSetting.defaultStorageChannelId) {
|
||||
await tx
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
|
||||
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDefaultOrganization() {
|
||||
const defaultOrganizationConf = {
|
||||
slug: "default",
|
||||
name: "Default Organization",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.organization)
|
||||
.where(eq(drizzleDb.schemas.organization.slug, "default"))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
log.info("==== Creating default Organization... ====");
|
||||
await db
|
||||
.insert(drizzleDb.schemas.organization)
|
||||
.values(defaultOrganizationConf);
|
||||
}
|
||||
}
|
||||
|
||||
function consoleAscii() {
|
||||
console.log(
|
||||
" \n" +
|
||||
" ____ __ __ \n" +
|
||||
" / __ \\____ _____/ /_____ _/ /_ ____ _________ \n" +
|
||||
" / /_/ / __ \\/ ___/ __/ __ / __ \\/ __ / ___/ _ \\ \n" +
|
||||
" / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ \n" +
|
||||
" /_/ \\____/_/ \\__/\\__,_/_.___/\\__,_/____/\\___/ \n" +
|
||||
" \n" +
|
||||
` Community Edition v${env.NEXT_PUBLIC_PROJECT_VERSION} \n ` +
|
||||
" \n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob, retentionJob} from "@/lib/tasks";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "init/cron"});
|
||||
|
||||
|
||||
export async function setupCronJobs() {
|
||||
log.info("==== Setting up Cron Jobs ====");
|
||||
retentionJob.start();
|
||||
cleaningJob.start();
|
||||
cleaningHealthcheckLogsJob.start();
|
||||
healthcheckAgentAndDatabaseJob.start();
|
||||
log.info("==== Cron jobs started ====");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { makeMigration } from "@/db";
|
||||
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {setupCronJobs} from "@/utils/init/cron";
|
||||
import {createSettingsIfNotExist} from "@/utils/init/setting";
|
||||
import {createDefaultOrganization} from "@/utils/init/organization";
|
||||
import {createDefaultUser} from "@/utils/init/user";
|
||||
|
||||
const log = logger.child({module: "init"});
|
||||
|
||||
export async function init() {
|
||||
consoleAscii();
|
||||
log.info("====Init Functions====");
|
||||
await getOrCreateMasterKey();
|
||||
await generateRSAKeys();
|
||||
await makeMigration();
|
||||
await createDefaultOrganization();
|
||||
await createSettingsIfNotExist();
|
||||
await createDefaultUser();
|
||||
log.info("====Initialization completed====");
|
||||
await setupCronJobs();
|
||||
if (
|
||||
(env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) ||
|
||||
(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET)
|
||||
) {
|
||||
log.warn(
|
||||
{
|
||||
deprecated: true,
|
||||
provider: "oauth_env",
|
||||
message: "You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)"
|
||||
},
|
||||
"Deprecated OAuth environment variables detected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function consoleAscii() {
|
||||
console.log(
|
||||
" \n" +
|
||||
" ____ __ __ \n" +
|
||||
" / __ \\____ _____/ /_____ _/ /_ ____ _________ \n" +
|
||||
" / /_/ / __ \\/ ___/ __/ __ / __ \\/ __ / ___/ _ \\ \n" +
|
||||
" / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ \n" +
|
||||
" /_/ \\____/_/ \\__/\\__,_/_.___/\\__,_/____/\\___/ \n" +
|
||||
" \n" +
|
||||
` Community Edition v${env.NEXT_PUBLIC_PROJECT_VERSION} \n ` +
|
||||
" \n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "init/organization"});
|
||||
|
||||
export async function createDefaultOrganization() {
|
||||
const defaultOrganizationConf = {
|
||||
slug: "default",
|
||||
name: "Default Organization",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.organization)
|
||||
.where(eq(drizzleDb.schemas.organization.slug, "default"))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
log.info("==== Creating default Organization... ====");
|
||||
await db
|
||||
.insert(drizzleDb.schemas.organization)
|
||||
.values(defaultOrganizationConf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {db} from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {StorageProviderKind} from "@/features/storages/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
|
||||
export async function createSettingsIfNotExist() {
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
const systemSettingsValues = {
|
||||
name: "system",
|
||||
smtpPassword: env.SMTP_PASSWORD ?? null,
|
||||
smtpFrom: env.SMTP_FROM ?? null,
|
||||
smtpHost: env.SMTP_HOST ?? null,
|
||||
smtpPort: env.SMTP_PORT ?? null,
|
||||
smtpUser: env.SMTP_USER ?? null,
|
||||
smtpSecure: env.SMTP_SECURE ?? false,
|
||||
};
|
||||
|
||||
const [systemSetting] = await tx
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
|
||||
const [finalSystemSetting] = systemSetting
|
||||
? await tx
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(systemSettingsValues)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(drizzleDb.schemas.setting)
|
||||
.values(systemSettingsValues)
|
||||
.returning();
|
||||
|
||||
const localStorageValues = {
|
||||
provider: "local" as StorageProviderKind,
|
||||
enabled: true,
|
||||
name: "System",
|
||||
config: {},
|
||||
};
|
||||
|
||||
const [existingLocalStorage] = await tx
|
||||
.select()
|
||||
.from(drizzleDb.schemas.storageChannel)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
|
||||
.limit(1);
|
||||
|
||||
const [localStorage] = existingLocalStorage
|
||||
? await tx
|
||||
.update(drizzleDb.schemas.storageChannel)
|
||||
.set(localStorageValues)
|
||||
.where(eq(drizzleDb.schemas.storageChannel.provider, "local"))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(drizzleDb.schemas.storageChannel)
|
||||
.values(localStorageValues)
|
||||
.returning();
|
||||
|
||||
if (!finalSystemSetting.defaultStorageChannelId) {
|
||||
await tx
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
|
||||
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {count, eq} from "drizzle-orm";
|
||||
import {logger} from "@/lib/logger";
|
||||
import type { SignUpUser } from "@/types/auth";
|
||||
import {env} from "@/env.mjs";
|
||||
import {createUserDb} from "@/db/services/user";
|
||||
|
||||
const log = logger.child({module: "init/user"});
|
||||
|
||||
export async function createDefaultUser() {
|
||||
|
||||
const hasRequiredEnv =
|
||||
env.AUTH_DEFAULT_USER &&
|
||||
env.AUTH_DEFAULT_PASSWORD &&
|
||||
env.AUTH_DEFAULT_USER_NAME;
|
||||
|
||||
if (!hasRequiredEnv) {
|
||||
log.info(
|
||||
"Default admin creation skipped: missing environment variables.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Checking default super admin...");
|
||||
|
||||
const existingSuperAdmin = await db.query.user.findFirst({
|
||||
where: eq(drizzleDb.schemas.user.role, "superadmin"),
|
||||
});
|
||||
|
||||
if (existingSuperAdmin) {
|
||||
log.info(
|
||||
`CreateDefaultUser skipped: a superadmin already exists (${existingSuperAdmin.email}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData: SignUpUser = {
|
||||
name: env.AUTH_DEFAULT_USER_NAME!,
|
||||
email: env.AUTH_DEFAULT_USER!,
|
||||
password: env.AUTH_DEFAULT_PASSWORD!,
|
||||
theme: "system",
|
||||
role: "superadmin",
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
|
||||
const newUser = await createUserDb(userData);
|
||||
|
||||
if (newUser) {
|
||||
log.info(`Default super admin created (${userData.email}).`);
|
||||
|
||||
const defaultOrgSlug = "default";
|
||||
const defaultOrg = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug),
|
||||
});
|
||||
|
||||
if (defaultOrg) {
|
||||
await db.insert(drizzleDb.schemas.member).values({
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrg.id,
|
||||
role: "owner",
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
"Default organization not found. Cannot assign member.",
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,61 @@ export function generateValidPassword(length = 12) {
|
||||
}
|
||||
|
||||
return passwordChars.join('');
|
||||
}
|
||||
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
const PASSWORD_RULES = [
|
||||
{
|
||||
key: "minLength",
|
||||
regex: /.{8,}/,
|
||||
message: "Password must contain at least 8 characters",
|
||||
},
|
||||
{
|
||||
key: "number",
|
||||
regex: /[0-9]/,
|
||||
message: "Password must contain at least 1 number",
|
||||
},
|
||||
{
|
||||
key: "lowercase",
|
||||
regex: /[a-z]/,
|
||||
message: "Password must contain at least 1 lowercase letter",
|
||||
},
|
||||
{
|
||||
key: "uppercase",
|
||||
regex: /[A-Z]/,
|
||||
message: "Password must contain at least 1 uppercase letter",
|
||||
},
|
||||
{
|
||||
key: "specialChar",
|
||||
regex: /[^a-zA-Z0-9]/,
|
||||
message: "Password must contain at least 1 special character",
|
||||
},
|
||||
];
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
): PasswordValidationResult {
|
||||
const failedRules = PASSWORD_RULES.filter(
|
||||
(rule) => !rule.regex.test(password),
|
||||
);
|
||||
|
||||
return {
|
||||
valid: failedRules.length === 0,
|
||||
errors: failedRules.map((r) => r.message),
|
||||
score: PASSWORD_RULES.length - failedRules.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertValidPassword(password: string) {
|
||||
const result = validatePassword(password);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new Error(result.errors.join(", "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user