Compare commits

..
39 Commits
Author SHA1 Message Date
github-actions[bot] 4fadfcfaf2 chore: release 1.15.4 2026-05-21 17:46:00 +00:00
af3df7f72a fix: multiple-webhook-headers and nextcloud talk provider (#296)
* feat(webhook): replace single secret header pair with webhookHeaders array schema

* feat(webhook): iterate webhookHeaders array; keep backward compat for legacy secret fields

* feat(webhook): replace single header pair with dynamic useFieldArray header list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(webhook): add field labels to header key/value inputs

* fix(webhook): guard reserved headers; align delete button with inputs

* feat(nextcloud): add nextcloud to provider_kind enum

* feat(nextcloud): add ProviderKind entry and HMAC-signed provider

* feat(nextcloud): add Zod schema and form component

* feat(nextcloud): wire nextcloud into form registry and UI provider list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(nextcloud): register sendNextcloud in provider dispatch map

* fix: multiple webhook headers and nextcloud talk notification provider

* test(webhook): update e2e to use Add Header button and Header Value label

* fix: webhook.ts

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:45:08 +02:00
github-actions[bot] 2cd5bda416 chore: release 1.15.3 2026-05-21 05:51:50 +00:00
965b215224 fix: replace integer column with bigint for size (#295)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:51:08 +02:00
github-actions[bot] 27f6b746e0 chore: release 1.15.2 2026-05-21 05:28:56 +00:00
7d463d7da8 fix: default user from init .env variables (#293)
* fix: default user from init en .env variables

* fix: .env.example

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-21 07:28:05 +02:00
github-actions[bot] 332540488d chore: release 1.15.1 2026-05-17 08:42:07 +00:00
ab52ef6c27 fix: logger.ts (#288)
* fix: release.yml

* fix: S3ChannelConfigSchema type port mismatch

* chore: package.json

* fix: refactoring logs and adding pino logger for production and dev.

* fix: trusted url error due to better auth update.

* fix: logger.ts

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-05-17 10:41:36 +02:00
github-actions[bot] 2d55618898 chore: release 1.15.0 2026-05-17 08:00:16 +00:00
Charles GTEandGitHub 6b3df405eb fix: add-healthcheck (#287)
fix: add-healthcheck
2026-05-17 09:59:38 +02:00
Charles GTE e91ef4c142 fix: docker-compose.prod.yml 2026-05-17 09:52:58 +02:00
Charles GTE 9b504ccd68 fix: proxy.ts 2026-05-17 09:46:03 +02:00
Charles GTE dd3f4411cf chore: add startupProbe and fix probe timing for cold-boot safety 2026-05-17 09:39:11 +02:00
Charles GTE 0ab94b6221 chore: add liveness and readiness probes to Helm deployment 2026-05-17 09:37:28 +02:00
Charles GTE 8477889cea chore: fix healthcheck start_period and clean up curl command 2026-05-17 09:36:49 +02:00
Charles GTE 2eb4a7a0fe chore: add healthcheck probe to prod docker-compose app service 2026-05-17 09:35:01 +02:00
Charles GTE 1f68adda90 feat: add GET /api/health liveness endpoint 2026-05-17 09:33:53 +02:00
Charles GTEandGitHub ffba2c56a3 fix: env
fix: env.mjs [skip-release]
2026-05-15 23:03:19 +02:00
github-actions[bot] 02be78a26e chore: release 1.14.1 2026-05-15 20:42:10 +00:00
Charles GTEandGitHub 711d4613ed fix: log noise #284
fix: logs noise
2026-05-15 22:41:21 +02:00
Charles GTE a5fe5225e1 fix: pin @better-auth/sso and @better-auth/passkey to 1.6.2 to fix build 2026-05-15 22:28:38 +02:00
Charles GTE b752d9437c chore: pnpm 2026-05-15 22:22:21 +02:00
Charles GTE 18df92e048 chore: update packages 2026-05-15 22:18:55 +02:00
Charles GTE 4e66b8ed04 fix: logs 2026-05-15 22:08:14 +02:00
Charles GTEandClaude Sonnet 4.6 4f14d16452 fix: replace console.log with Pino logger in server-side files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:05:13 +02:00
Charles GTE 592588ef76 docs: set LOG_LEVEL=info as dev default in .env.example 2026-05-15 22:01:19 +02:00
Charles GTE 3fec7c3392 docs: document LOG_LEVEL env var in .env.example 2026-05-15 21:59:00 +02:00
Charles GTE 2335caefd7 fix: downgrade cleaningJob logs to debug; fix catch log level 2026-05-15 21:57:56 +02:00
Charles GTE 194eedeaa8 fix: downgrade agent status ping log to debug 2026-05-15 21:57:21 +02:00
Charles GTE ecd09aedb9 fix: cache log level index at module init in loggingMiddleware 2026-05-15 21:56:37 +02:00
Charles GTE b5350b5fe3 fix: .gitignore 2026-05-15 21:55:19 +02:00
Charles GTE ceaafd5531 fix: suppress agent status ping logs below debug level 2026-05-15 21:55:01 +02:00
Charles GTE 654accad4a fix: use nullish coalescing for LOG_LEVEL fallback 2026-05-15 21:53:54 +02:00
Charles GTE 173c7894b4 fix: respect LOG_LEVEL env var in Pino logger 2026-05-15 21:52:51 +02:00
Charles GTE 1ab51d15f9 fix: added mailpit 2026-05-15 21:24:11 +02:00
Charles GTEandGitHub ddce51d2d3 fix: README.md [skip-release] (#280) 2026-05-12 20:03:49 +02:00
Charles GTEandGitHub fc9fbf4357 chore: update the README.md with mssql (#278) [skip-release] 2026-05-11 22:27:19 +02:00
github-actions[bot] 5e8cd7e0e6 chore: release 1.14.0 2026-05-11 20:09:21 +00:00
Charles GTEandGitHub 1eb5fbb983 feat: add support for mssql (#277)
* feat: add support for mssql

* fix: common.ts
2026-05-11 22:08:36 +02:00
60 changed files with 10992 additions and 1953 deletions
+15
View File
@@ -1,6 +1,12 @@
# Environment # Environment
NODE_ENV=development 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
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
@@ -9,6 +15,15 @@ PROJECT_NAME="Portabase"
PROJECT_URL=http://localhost:8887 PROJECT_URL=http://localhost:8887
PROJECT_SECRET= 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 (email)
SMTP_HOST= SMTP_HOST=
SMTP_PORT= SMTP_PORT=
+4
View File
@@ -55,3 +55,7 @@ certificates
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
/e2e/local-storage.json /e2e/local-storage.json
.claude
.codex
/docs
+3 -1
View File
@@ -21,6 +21,8 @@ keywords:
- backups - backups
- postgresql - postgresql
- mysql - mysql
- firebird
- mssql
- mariadb - mariadb
- devops - devops
- database - database
@@ -31,5 +33,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.13.1 version: 1.15.4
date-released: '2026-03-02' date-released: '2026-03-02'
+12 -11
View File
@@ -25,6 +25,7 @@
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/) [![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/) [![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/) [![Firebird](https://img.shields.io/badge/Firebird-f55b14?style=flat&logo=Firebird&logoColor=white)](https://firebirdsql.org/)
[![Microsoft SQL Server](https://img.shields.io/badge/Microsoft%20SQL%20Server-CC2927?style=flat&logo=microsoftsqlserver&logoColor=white)](https://www.microsoft.com/en-us/sql-server)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase) [![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase) [![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -65,17 +66,17 @@ You have 4 ways to install Portabase:
## Supported databases ## Supported databases
| Engine | Support | Supported Versions | Restore | | Engine | Support | Supported Versions | Restore |
|:-------------------|:-----------|:------------------------------|:--------| |:-------------------|:----------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes | | **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes | | **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
| **MariaDB** | ✅ Stable | 10 and 11 | Yes | | **MariaDB** | ✅ Stable | 10 and 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes | | **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes | | **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No | | **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No | | **Valkey** | ✅ Stable | 7.2+ | No |
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes | | **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
| **MSSQL Server** | ❌ Ongoing | - | 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. 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 {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db"; import {db} from "@/db";
import {logger} from "@/lib/logger";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs"; import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
import {desc, isNull} from "drizzle-orm"; import {desc, isNull} from "drizzle-orm";
@@ -8,13 +9,15 @@ import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
const log = logger.child({module: "dashboard/admin/settings"});
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({ const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"), where: (fields, {eq}) => eq(fields.name, "system"),
}); });
console.log(settings) log.debug({settings}, "Settings loaded");
const storageChannels = await db.query.storageChannel.findMany({ const storageChannels = await db.query.storageChannel.findMany({
with: { with: {
@@ -33,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}) as NotificationChannelWith[] }) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels ) { if (!settings || !storageChannels || !notificationChannels) {
notFound() notFound()
} }
@@ -45,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} /> <SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
settings={settings}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -30,9 +30,11 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader className="flex flex-col"> <PageHeader className="flex flex-col">
<div className="flex justify-between"> <div className="flex justify-between">
<PageTitle className="mb-3">Active users</PageTitle> <PageTitle className="mb-3">Active users</PageTitle>
<PageActions> {isPasswordAuthEnabled && (
<AdminUserAddModal organizations={organizations}/> <PageActions>
</PageActions> <AdminUserAddModal organizations={organizations}/>
</PageActions>
)}
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
-4
View File
@@ -9,8 +9,4 @@ export default async function Index() {
redirect(`/dashboard/home`); redirect(`/dashboard/home`);
} }
redirect("/login"); redirect("/login");
//Do not delete
// return (
// <Home/>
// );
} }
+1 -1
View File
@@ -31,7 +31,7 @@ export async function POST(
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId
log.info(`Agent ID: ${agentId}`) log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ success: true });
}
+11 -5
View File
@@ -1,10 +1,10 @@
services: services:
app: app:
# build: # build:
# context: . # context: .
# dockerfile: docker/dockerfile/Dockerfile # dockerfile: docker/dockerfile/Dockerfile
# target: prod # target: prod
image: portabase/portabase:1.7.1 image: portabase/portabase:1
ports: ports:
- '8887:80' - '8887:80'
environment: environment:
@@ -17,6 +17,12 @@ services:
db: db:
condition: service_healthy condition: service_healthy
container_name: portabase-app-prod 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: db:
image: postgres:16-alpine image: postgres:16-alpine
+10 -1
View File
@@ -12,7 +12,7 @@ services:
- POSTGRES_USER=devuser - POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme - POSTGRES_PASSWORD=changeme
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -32,5 +32,14 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp - ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000" user: "1000:1000"
mailpit:
image: axllent/mailpit:latest
container_name: portabase-mailpit-dev
hostname: mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "1025:1025"
volumes: volumes:
postgres-data: postgres-data:
+4 -2
View File
@@ -31,8 +31,9 @@ const invalidChannelName = "Webhook E2E Invalid";
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible(); // await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", optionalChannelName, async (page) => { // await create(page, "Webhook", optionalChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL")); // 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(/^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 submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible(); // 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 expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Webhook", invalidChannelName, async (page) => { await create(page, "Webhook", invalidChannelName, async (page) => {
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook"); 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(/^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 submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible(); await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
+24
View File
@@ -27,6 +27,30 @@ spec:
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /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: volumes:
- name: data - name: data
persistentVolumeClaim: persistentVolumeClaim:
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.13.1", "version": "1.15.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -16,8 +16,8 @@
}, },
"dependencies": { "dependencies": {
"@better-auth/core": "1.6.2", "@better-auth/core": "1.6.2",
"@better-auth/passkey": "^1.6.2", "@better-auth/passkey": "1.6.2",
"@better-auth/sso": "^1.6.2", "@better-auth/sso": "1.6.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -128,7 +128,7 @@
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"postcss": "^8.5.9", "postcss": "8.5.10",
"release-it": "^19.2.4", "release-it": "^19.2.4",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -136,5 +136,5 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.33.0" "packageManager": "pnpm@11.1.3"
} }
+2038 -1547
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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: ignoredBuiltDependencies:
- node-pty - node-pty
onlyBuiltDependencies: onlyBuiltDependencies:
+1
View File
@@ -71,6 +71,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/tus\/hooks\/?$/, /^\/api\/tus\/hooks\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
]; ];
return routePatterns.some((pattern) => pattern.test(pathname)); return routePatterns.some((pattern) => pattern.test(pathname));
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -6,6 +6,7 @@ import {TelegramChannelConfigSchema} from "./providers/notifications/forms/teleg
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema"; import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema"; import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.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 {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema"; import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema"; import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
@@ -48,6 +49,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("webhook"), provider: z.literal("webhook"),
config: WebhookChannelConfigSchema, config: WebhookChannelConfigSchema,
}), }),
BaseChannelFormSchema.extend({
provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema,
}),
]); ]);
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
@@ -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>
)}
/>
</>
);
};
@@ -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"),
});
@@ -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 { import {
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; 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 = { type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>; form: UseFormReturn<any, any, any>;
}; };
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => { export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return ( const { fields, append, remove } = useFieldArray({
<> control: form.control,
<Separator className="my-1" /> name: "config.webhookHeaders",
<FormField });
control={form.control}
name="config.webhookUrl" return (
render={({ field }) => ( <>
<FormItem> <Separator className="my-1" />
<FormLabel>Webhook URL *</FormLabel> <FormField
<FormControl> control={form.control}
<Input {...field} placeholder="e.g. https://example.com/api/webhook" /> name="config.webhookUrl"
</FormControl> render={({ field }) => (
<FormMessage /> <FormItem>
</FormItem> <FormLabel>Webhook URL *</FormLabel>
)} <FormControl>
/> <Input
<div className="flex gap-4"> {...field}
<div className="flex-1"> placeholder="e.g. https://example.com/api/webhook"
<FormField />
control={form.control} </FormControl>
name="config.webhookSecretHeader" <FormMessage />
render={({ field }) => ( </FormItem>
<FormItem> )}
<FormLabel>Header Name</FormLabel> />
<FormControl>
<Input {...field} placeholder="e.g. X-Webhook-Secret" /> <div className="space-y-3">
</FormControl> <div className="flex items-center justify-between">
<FormMessage /> <Label className="text-sm font-medium">Custom Headers</Label>
</FormItem> <Button
)} type="button"
/> variant="outline"
</div> size="sm"
<div className="flex-1"> onClick={() => append({ key: "", value: "" })}
<FormField >
control={form.control} <Plus className="w-4 h-4 mr-2" />
name="config.webhookSecret" Add Header
render={({ field }) => ( </Button>
<FormItem> </div>
<FormLabel>Secret Value</FormLabel>
<FormControl> {fields.length === 0 && (
<PasswordInput {...field} placeholder="e.g. webhook-secret" /> <p className="text-xs text-muted-foreground">
</FormControl> No custom headers. Add headers to send with each webhook request.
<FormMessage /> </p>
</FormItem> )}
)}
/> <div className="space-y-2">
</div> {fields.map((headerField, index) => (
</div> <div key={headerField.id} className="flex gap-2">
<p className="text-xs text-muted-foreground mt-2"> <div className="flex-1">
If provided, the secret will be sent in the specified header (defaults <FormField
to <code>X-Webhook-Secret</code>). control={form.control}
</p> 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>
</>
);
}; };
@@ -1,7 +1,16 @@
import {z} from "zod"; import { z } from "zod";
export const WebhookChannelConfigSchema = z.object({ export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"), webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(), webhookHeaders: z
webhookSecret: z.string().optional(), .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 { import {
NotifierWebhookForm NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form"; } 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 { import {
notificationProviders, notificationProviders,
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; } from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
@@ -87,6 +90,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierNtfyForm form={form}/>; return <NotifierNtfyForm form={form}/>;
case "webhook": case "webhook":
return <NotifierWebhookForm form={form}/>; return <NotifierWebhookForm form={form}/>;
case "nextcloud":
return <NotifierNextcloudForm form={form}/>;
case "s3": case "s3":
return <StorageS3Form form={form}/> return <StorageS3Form form={form}/>
case "google-drive": case "google-drive":
@@ -13,6 +13,7 @@ export const notificationProviders: ProviderIconTypes[] = [
{value: "gotify", label: "Gotify", icon: GotifyIcon}, {value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon}, {value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true} {value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
] ]
@@ -530,3 +531,28 @@ export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
</svg>) </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, email: parsedInput.email,
theme: "dark", theme: "dark",
role: "user", role: "user",
password: ""
}; };
if (isPasswordAuthEnabled) { if (isPasswordAuthEnabled) {
@@ -1,13 +1,16 @@
"use server"; "use server";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import { logger } from "@/lib/logger";
import {z} from "zod"; import {z} from "zod";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type"; import {ServerActionResult} from "@/types/action-type";
import {and, eq, inArray} from "drizzle-orm"; import {eq, inArray} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb 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>> => { export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
try { try {
const uuid = uuidv4(); const uuid = uuidv4();
@@ -51,7 +54,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
}, },
}; };
} catch (error) { } catch (error) {
console.log(error); log.error({ error }, "Failed to archive project");
return { return {
success: false, success: false,
actionError: { 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;
+1
View File
@@ -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
+21
View File
@@ -379,6 +379,27 @@
"when": 1777204042208, "when": 1777204042208,
"tag": "0053_lyrical_union_jack", "tag": "0053_lyrical_union_jack",
"breakpoints": true "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
} }
] ]
} }
+2 -2
View File
@@ -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 {Agent, agent, AgentWith} from "./08_agent";
import {Project, project} from "./06_project"; import {Project, project} from "./06_project";
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
@@ -37,7 +37,7 @@ export const backup = pgTable(
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
status: statusEnum("status").default("waiting").notNull(), status: statusEnum("status").default("waiting").notNull(),
file: text("file"), file: text("file"),
fileSize: integer("file_size"), fileSize: bigint("file_size", { mode: "number" }),
databaseId: uuid("database_id") databaseId: uuid("database_id")
.notNull() .notNull()
.references(() => database.id, {onDelete: "cascade"}), .references(() => database.id, {onDelete: "cascade"}),
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation"; 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', { export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
+2 -2
View File
@@ -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 { timestamps } from "@/db/schema/00_common";
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel"; import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
import {Backup, backup, Restoration} from "@/db/schema/07_database"; import {Backup, backup, Restoration} from "@/db/schema/07_database";
@@ -22,7 +22,7 @@ export const backupStorage = pgTable("backup_storage", {
.references(() => storageChannel.id, { onDelete: "cascade" }), .references(() => storageChannel.id, { onDelete: "cascade" }),
status: backupStorageStatusEnum("status").notNull().default("pending"), status: backupStorageStatusEnum("status").notNull().default("pending"),
path: text("path"), path: text("path"),
size: integer("size"), size: bigint("size", { mode: "number" }),
checksum: text("checksum"), checksum: text("checksum"),
...timestamps, ...timestamps,
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "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 statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]); export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+4
View File
@@ -3,13 +3,17 @@ import {hashPassword} from "better-auth/crypto";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user"; import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
export async function createUserDb(data: SignUpUser): Promise<User> { export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
const now = new Date(); const now = new Date();
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({ const [newUser] = await db.insert(drizzleDb.schemas.user).values({
...data,
id: userId, id: userId,
name: data.name, name: data.name,
email: data.email, email: data.email,
+134 -125
View File
@@ -1,165 +1,174 @@
import { createEnv } from "@t3-oss/env-nextjs"; import {createEnv} from "@t3-oss/env-nextjs";
import path from "path"; import path from "path";
import { z } from "zod"; import {z} from "zod";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with {type: "json"};
const { version } = packageJson; const {version} = packageJson;
export const env = createEnv({ export const env = createEnv({
server: { server: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), 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_NAME: z.string().optional(),
PROJECT_DESCRIPTION: z.string().optional(), PROJECT_DESCRIPTION: z.string().optional(),
PROJECT_URL: z PROJECT_URL: z
.string() .string()
.regex(/^https?:\/\//, "URL must start with http:// or https://"), .regex(/^https?:\/\//, "URL must start with http:// or https://"),
PROJECT_SECRET: z.string(), PROJECT_SECRET: z.string(),
TRUSTED_DOMAINS: z.string().optional(), TRUSTED_DOMAINS: z.string().optional(),
SMTP_PASSWORD: z.string().optional(), SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(), SMTP_FROM: z.string().optional(),
SMTP_HOST: z.string().optional(), SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(), SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
AUTH_DEFAULT_USER_NAME: z.string().optional(),
AUTH_DEFAULT_USER: z.string().optional(),
AUTH_DEFAULT_PASSWORD: z.string().optional(),
SMTP_SECURE: z SMTP_SECURE: z
.enum(["true", "false"]) .enum(["true", "false"])
.transform((val) => val === "true") .transform((val) => val === "true")
.default("true"), .default("true"),
AUTH_GOOGLE_ID: z.string().optional(), AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(), AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GITHUB_ID: z.string().optional(), AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(), AUTH_GITHUB_SECRET: z.string().optional(),
RETENTION_CRON: z RETENTION_CRON: z
.string() .string()
.default( .default(
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *", process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
), ),
CLEANING_HEALTHCHECK_LOGS_CRON: z CLEANING_HEALTHCHECK_LOGS_CRON: z
.string() .string()
.default( .default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *", process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
), ),
HEALTHCHECK_CRON: z HEALTHCHECK_CRON: z
.string() .string()
.default( .default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *", 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_SOCIAL_ID: z.string().optional().default("social"),
AUTH_OIDC_TITLE: z.string().optional(), AUTH_SOCIAL_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(), AUTH_SOCIAL_DESC: z.string().optional(),
AUTH_OIDC_ICON: z.string().optional(), AUTH_SOCIAL_ICON: z.string().optional(),
AUTH_OIDC_CLIENT: z.string().optional(), AUTH_SOCIAL_CLIENT: z.string().optional(),
AUTH_OIDC_SECRET: z.string().optional(), AUTH_SOCIAL_SECRET: z.string().optional(),
AUTH_OIDC_ISSUER_URL: z.string().optional(), AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: 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"), ALLOWED_GROUP: z.string().optional(),
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(), 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_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_ROLE_MAP: z.string().optional(),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"), 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"), PRIVATE_PATH: z.string().optional(),
AUTH_ROLE_MAP: z.string().optional(), },
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"), client: {
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"), NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"), },
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
LOG_LEVEL: process.env.LOG_LEVEL,
PRIVATE_PATH: z.string().optional(), PROJECT_NAME: process.env.PROJECT_NAME,
}, PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
client: { PROJECT_URL: process.env.PROJECT_URL,
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), PROJECT_SECRET: process.env.PROJECT_SECRET,
},
runtimeEnv: {
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
PROJECT_NAME: process.env.PROJECT_NAME, DATABASE_URL: process.env.DATABASE_URL,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_SECRET: process.env.PROJECT_SECRET,
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, RETENTION_CRON: process.env.RETENTION_CRON,
SMTP_FROM: process.env.SMTP_FROM, CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
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, AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON, 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_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE, AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
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_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET, AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID, AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET, 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, ALLOWED_GROUP: process.env.ALLOWED_GROUP,
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, 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_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
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_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, PRIVATE_PATH:
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING, process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
PRIVATE_PATH: AUTH_DEFAULT_USER_NAME: process.env.AUTH_DEFAULT_USER_NAME,
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"), 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 {sendGotify} from "@/features/notifications/providers/gotify";
import {sendNtfy} from "@/features/notifications/providers/ntfy"; import {sendNtfy} from "@/features/notifications/providers/ntfy";
import {sendWebhook} from "@/features/notifications/providers/webhook"; import {sendWebhook} from "@/features/notifications/providers/webhook";
import {sendNextcloud} from "@/features/notifications/providers/nextcloud";
const handlers: Record< const handlers: Record<
ProviderKind, ProviderKind,
@@ -18,7 +19,8 @@ const handlers: Record<
telegram: sendTelegram, telegram: sendTelegram,
gotify: sendGotify, gotify: sendGotify,
ntfy: sendNtfy, ntfy: sendNtfy,
webhook: sendWebhook webhook: sendWebhook,
nextcloud: sendNextcloud,
}; };
export async function dispatchViaProvider( 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( export async function sendWebhook(
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string }, config: WebhookConfig,
payload: EventPayload payload: EventPayload
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const {webhookUrl, webhookSecret, webhookSecretHeader} = config; const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', '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; headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
} }
const res = await fetch(webhookUrl, { const res = await fetch(webhookUrl, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: headers, headers,
}); });
if (!res.ok) { if (!res.ok) {
+1 -1
View File
@@ -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 { export interface DispatchResult {
success: boolean; success: boolean;
+4 -1
View File
@@ -11,6 +11,9 @@ import {StorageChannel} from "@/db/schema/12_storage-channel";
import { import {
StorageChannelFormType StorageChannelFormType
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema"; } 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( export async function dispatchStorage(
@@ -103,7 +106,7 @@ export async function dispatchStorage(
input, input,
); );
console.log(dispatchResult); log.debug({ result: dispatchResult }, "Storage dispatch result");
return dispatchResult; return dispatchResult;
} catch (err: any) { } catch (err: any) {
+4 -3
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {logger} from "@/lib/logger";
import {z} from "zod"; import {z} from "zod";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {db} from "@/db"; import {db} from "@/db";
@@ -10,6 +11,7 @@ import {dispatchStorage} from "@/features/storages/dispatch";
import {StorageInput} from "@/features/storages/types"; import {StorageInput} from "@/features/storages/types";
import sharp from "sharp"; import sharp from "sharp";
const log = logger.child({module: "features/upload/upload.action"});
export const uploadUserImageAction = userAction.schema( export const uploadUserImageAction = userAction.schema(
z.instanceof(FormData) z.instanceof(FormData)
@@ -27,7 +29,7 @@ export const uploadUserImageAction = userAction.schema(
const isWebp = fileFormat === "webp"; const isWebp = fileFormat === "webp";
const compressedBuffer = await sharp(buffer) const compressedBuffer = await sharp(buffer)
.resize({ width: 1024 }) .resize({width: 1024})
.toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", { .toFormat(isPng ? "png" : isWebp ? "webp" : "jpeg", {
quality: 80, quality: 80,
compressionLevel: isPng ? 9 : undefined compressionLevel: isPng ? 9 : undefined
@@ -35,7 +37,6 @@ export const uploadUserImageAction = userAction.schema(
.toBuffer(); .toBuffer();
const settings = await db.query.setting.findFirst({ const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"), where: eq(drizzleDb.schemas.setting.name, "system"),
with: { with: {
@@ -69,7 +70,7 @@ export const uploadUserImageAction = userAction.schema(
} }
const result = await dispatchStorage(input, undefined, settings.storageChannel.id); const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
console.log(result); log.debug({result}, "Upload dispatch result");
if (!result.success) { if (!result.success) {
return { return {
success: false, success: false,
+6 -6
View File
@@ -22,7 +22,9 @@ import {passkey} from "@better-auth/passkey";
import {getOidcProviders} from "./oidc"; import {getOidcProviders} from "./oidc";
import {APIError} from "better-auth/api"; import {APIError} from "better-auth/api";
import {getOAuthProviders} from "./oauth"; import {getOAuthProviders} from "./oauth";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "lib/auth" });
const oidcProviders = getOidcProviders(); const oidcProviders = getOidcProviders();
@@ -438,9 +440,7 @@ export const auth = betterAuth({
new Date(user.createdAt).getTime(); new Date(user.createdAt).getTime();
if (createdAtDiff < 5000) { if (createdAtDiff < 5000) {
console.log( log.debug(`Skipping new login email for freshly created user ${user.email}`);
`Skipping new login email for freshly created user ${user.email}`,
);
return; return;
} }
@@ -471,12 +471,12 @@ export const auth = betterAuth({
), ),
}); });
} catch (error) { } catch (error) {
console.log("sendEmail failed", { log.error({
userId: user.id, userId: user.id,
details: "Please Check your env variables or system config", details: "Please Check your env variables or system config",
email: user.email, email: user.email,
error, error,
}); }, "sendEmail failed");
} }
(await auth.$context).internalAdapter.updateUser(user.id, { (await auth.$context).internalAdapter.updateUser(user.id, {
@@ -784,7 +784,7 @@ export const getActiveMember = async () => {
return member as MemberWithUser; return member as MemberWithUser;
} catch (e) { } catch (e) {
console.log("err", e); log.error({ error: e }, "Auth error");
} }
}; };
+3 -2
View File
@@ -1,6 +1,8 @@
import pino, { type Logger } from "pino"; import pino, { type Logger } from "pino";
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env.NODE_ENV === "production";
const defaultLevel = isProd ? "info" : "debug";
const level = (process.env.LOG_LEVEL ?? defaultLevel) as pino.Level;
function getLocalTimestamp() { function getLocalTimestamp() {
const date = new Date(); const date = new Date();
@@ -19,9 +21,8 @@ function getLocalTimestamp() {
} }
export const logger: Logger = pino({ export const logger: Logger = pino({
level: isProd ? "info" : "debug", level: level,
base: null, base: null,
...(isProd ...(isProd
? { ? {
timestamp: getLocalTimestamp, timestamp: getLocalTimestamp,
+2 -2
View File
@@ -14,7 +14,7 @@ export const backupCleanTask = async () => {
eq(drizzleDb.schemas.backup.status, "ongoing") 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) { for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({ await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
@@ -24,7 +24,7 @@ export const backupCleanTask = async () => {
} }
} catch (e: any) { } catch (e: any) {
log.info({name: "backupCleanTask", error: e},`Backup cleanup failed`); log.error({name: "backupCleanTask", error: e},`Backup cleanup failed`);
throw e; throw e;
} }
}; };
+1 -1
View File
@@ -22,7 +22,7 @@ export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
export const cleaningJob = cron.schedule("* * * * *", async () => { export const cleaningJob = cron.schedule("* * * * *", async () => {
try { 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(); await backupCleanTask();
} catch (err) { } catch (err) {
log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error"); log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error");
+15 -1
View File
@@ -1,8 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'; 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) { export function loggingMiddleware(request: NextRequest) {
if (request.url.includes('/api')) { 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(); return NextResponse.next();
} }
+2 -2
View File
@@ -1,11 +1,11 @@
export type SignUpUser = { export type SignUpUser = {
name: string name: string
email: string email: string
password?: string password: string
callbackURL?: string callbackURL?: string
role?: string role?: string
theme: string theme: string
emailVerified?: boolean
} }
+4
View File
@@ -78,6 +78,10 @@ export function getFileHeadersBasedOnDbms(dbType: string): Record<string, string
return { return {
"application/octet-stream": [".backup"], "application/octet-stream": [".backup"],
}; };
case "mssql":
return {
"application/octet-stream": [".bacpac"],
};
default: default:
throw new Error(`Unsupported database type: ${dbType}`); throw new Error(`Unsupported database type: ${dbType}`);
-146
View File
@@ -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",
);
}
+14
View File
@@ -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 ====");
}
+50
View File
@@ -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",
);
}
+27
View File
@@ -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);
}
}
+70
View File
@@ -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));
}
});
}
+71
View File
@@ -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.",
);
}
}
}
+57
View File
@@ -24,4 +24,61 @@ export function generateValidPassword(length = 12) {
} }
return passwordChars.join(''); 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(", "));
}
} }