Compare commits

..
18 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
40 changed files with 6181 additions and 366 deletions
+9
View File
@@ -15,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=
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.14.1
version: 1.15.4
date-released: '2026-03-02'
@@ -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">
+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:
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
+4 -2
View File
@@ -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();
+24
View File
@@ -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:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.14.1",
"version": "1.15.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -136,5 +136,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499"
"packageManager": "pnpm@11.1.3"
}
+1
View File
@@ -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));
@@ -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", [
@@ -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 {
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>
</>
);
};
@@ -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) {
@@ -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
+14
View File
@@ -386,6 +386,20 @@
"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 {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"}),
+1 -1
View File
@@ -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(),
+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 {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,
});
+4
View File
@@ -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
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 { 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 -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 {
success: boolean;
+1 -2
View File
@@ -21,9 +21,8 @@ function getLocalTimestamp() {
}
export const logger: Logger = pino({
level,
level: level,
base: null,
...(isProd
? {
timestamp: getLocalTimestamp,
+2 -2
View File
@@ -1,11 +1,11 @@
export type SignUpUser = {
name: string
email: string
password?: string
password: string
callbackURL?: string
role?: string
theme: string
emailVerified?: boolean
}
-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('');
}
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(", "));
}
}