mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: allow approved private notification hosts (#1330)
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||||
|
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound HTTP for automation steps and notification channels.
|
* Outbound HTTP for automation steps and notification channels.
|
||||||
*
|
*
|
||||||
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
||||||
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
||||||
* on a LAN that many installs actually use. Rather than weaken the guard
|
* on a LAN that many installs actually use. Private delivery therefore needs
|
||||||
* globally, a destination can opt in explicitly; everything else about the
|
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||||
* guard (scheme, embedded credentials, no redirects) still applies.
|
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||||
*/
|
*/
|
||||||
export interface AutomationFetchOptions {
|
export interface AutomationFetchOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -52,8 +53,8 @@ export async function automationFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opt-in path. Keeps the parts of the guard that are always right and
|
* The opt-in path still goes through the guarded resolver. Only an exact host
|
||||||
* drops only the address blocklist.
|
* authorized by an administrator may resolve to a private address.
|
||||||
*/
|
*/
|
||||||
async function privateNetworkFetch(
|
async function privateNetworkFetch(
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
@@ -73,5 +74,6 @@ async function privateNetworkFetch(
|
|||||||
throw new Error("URLs with embedded credentials are not allowed");
|
throw new Error("URLs with embedded credentials are not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(rawUrl, { ...init, redirect: "error" });
|
const allowlist = await readNotificationPrivateAllowlist();
|
||||||
|
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
} from "../../utils/audit-forwarder.js";
|
} from "../../utils/audit-forwarder.js";
|
||||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||||
|
import {
|
||||||
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
parseNotificationAllowlist,
|
||||||
|
} from "../../utils/notification-egress.js";
|
||||||
import {
|
import {
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
@@ -1078,6 +1082,90 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/notification-private-endpoints",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
if (!(await getAdminActor(userId))) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const raw = await createCurrentSettingsRepository().get(
|
||||||
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
);
|
||||||
|
res.json({ hosts: parseNotificationAllowlist(raw) });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get notification endpoint allowlist", err);
|
||||||
|
res.status(500).json({ error: "Failed to get the allowlist" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/notification-private-endpoints",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hosts } = req.body;
|
||||||
|
if (!Array.isArray(hosts)) {
|
||||||
|
return res.status(400).json({ error: "hosts must be an array" });
|
||||||
|
}
|
||||||
|
if (hosts.length > 50) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "At most 50 hosts are allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned: string[] = [];
|
||||||
|
for (const entry of hosts) {
|
||||||
|
if (typeof entry !== "string") {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Each host must be a string" });
|
||||||
|
}
|
||||||
|
const host = entry.trim().toLowerCase();
|
||||||
|
if (!host) continue;
|
||||||
|
if (!/^[a-z0-9._:-]+$/.test(host)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: `${entry} is not a valid hostname` });
|
||||||
|
}
|
||||||
|
if (!cleaned.includes(host)) cleaned.push(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
JSON.stringify(cleaned),
|
||||||
|
);
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_notification_private_endpoints",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ hosts: cleaned }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
res.json({ hosts: cleaned });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to update notification endpoint allowlist",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to update the allowlist" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/host-defaults:
|
* /users/host-defaults:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseNotificationAllowlist } from "../../utils/notification-egress.js";
|
||||||
|
|
||||||
|
describe("parseNotificationAllowlist", () => {
|
||||||
|
it("defaults to an empty list", () => {
|
||||||
|
expect(parseNotificationAllowlist(null)).toEqual([]);
|
||||||
|
expect(parseNotificationAllowlist("invalid")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes configured hosts", () => {
|
||||||
|
expect(
|
||||||
|
parseNotificationAllowlist(
|
||||||
|
JSON.stringify([" NTFY.Internal ", "192.168.1.20", 42, ""]),
|
||||||
|
),
|
||||||
|
).toEqual(["ntfy.internal", "192.168.1.20"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ function runHook(
|
|||||||
addresses: LookupAddress[] | string | undefined,
|
addresses: LookupAddress[] | string | undefined,
|
||||||
error: NodeJS.ErrnoException | null = null,
|
error: NodeJS.ErrnoException | null = null,
|
||||||
lookupOptions: LookupOptions = { all: true },
|
lookupOptions: LookupOptions = { all: true },
|
||||||
|
allowPrivate = false,
|
||||||
) {
|
) {
|
||||||
const fakeLookup = vi.fn(
|
const fakeLookup = vi.fn(
|
||||||
(
|
(
|
||||||
@@ -66,7 +67,7 @@ function runHook(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const hook = createDnsLookupHook(fakeLookup);
|
const hook = createDnsLookupHook(fakeLookup, allowPrivate);
|
||||||
const callback = vi.fn();
|
const callback = vi.fn();
|
||||||
|
|
||||||
hook("example.invalid", lookupOptions, callback);
|
hook("example.invalid", lookupOptions, callback);
|
||||||
@@ -92,6 +93,21 @@ const publicAddresses: LookupAddress[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("createDnsLookupHook", () => {
|
describe("createDnsLookupHook", () => {
|
||||||
|
it("permits private results only for an explicitly authorized host", () => {
|
||||||
|
const { callback } = runHook(
|
||||||
|
[{ address: "192.168.1.20", family: 4 }],
|
||||||
|
null,
|
||||||
|
{ all: true },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(
|
||||||
|
null,
|
||||||
|
[{ address: "192.168.1.20", family: 4 }],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows a public IPv4 address through", () => {
|
it("allows a public IPv4 address through", () => {
|
||||||
const { callback } = runHook([
|
const { callback } = runHook([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||||
import { statsLogger } from "./logger.js";
|
import { statsLogger } from "./logger.js";
|
||||||
|
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||||
|
|
||||||
export interface DiscordConfig {
|
export interface DiscordConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -14,7 +15,8 @@ async function fetchWithRetry(
|
|||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const res = await safeOutboundFetch(url, options);
|
const allowlist = await readNotificationPrivateAllowlist();
|
||||||
|
const res = await safeOutboundFetch(url, options, allowlist);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let body = "";
|
let body = "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||||
|
|
||||||
|
export const NOTIFICATION_PRIVATE_ALLOWLIST_KEY =
|
||||||
|
"notification_private_endpoint_allowlist";
|
||||||
|
|
||||||
|
export function parseNotificationAllowlist(raw: string | null): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readNotificationPrivateAllowlist(): Promise<string[]> {
|
||||||
|
const raw = await createCurrentSettingsRepository().get(
|
||||||
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
|
);
|
||||||
|
return parseNotificationAllowlist(raw);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { statsLogger } from "./logger.js";
|
import { statsLogger } from "./logger.js";
|
||||||
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||||
|
import { readNotificationPrivateAllowlist } from "./notification-egress.js";
|
||||||
|
|
||||||
export interface AlertPayload {
|
export interface AlertPayload {
|
||||||
hostName: string;
|
hostName: string;
|
||||||
@@ -37,7 +38,8 @@ async function fetchWithRetry(
|
|||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const res = await safeOutboundFetch(url, options);
|
const allowlist = await readNotificationPrivateAllowlist();
|
||||||
|
const res = await safeOutboundFetch(url, options, allowlist);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export function isBlockedAddress(address: string): boolean {
|
|||||||
// fake DNS resolver, instead of only through a real fetch()/Agent call —
|
// fake DNS resolver, instead of only through a real fetch()/Agent call —
|
||||||
// the actual bug here lived entirely in this callback, several layers
|
// the actual bug here lived entirely in this callback, several layers
|
||||||
// below where undici's own "fetch failed" wrapping would otherwise hide it.
|
// below where undici's own "fetch failed" wrapping would otherwise hide it.
|
||||||
export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
export function createDnsLookupHook(
|
||||||
|
dnsLookup: DnsLookupFn = lookup,
|
||||||
|
allowPrivate = false,
|
||||||
|
) {
|
||||||
return function lookupHook(
|
return function lookupHook(
|
||||||
host: string,
|
host: string,
|
||||||
lookupOptions: LookupOptions,
|
lookupOptions: LookupOptions,
|
||||||
@@ -110,7 +113,10 @@ export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addrs.some(({ address }) => isBlockedAddress(address))) {
|
if (
|
||||||
|
!allowPrivate &&
|
||||||
|
addrs.some(({ address }) => isBlockedAddress(address))
|
||||||
|
) {
|
||||||
return callback(
|
return callback(
|
||||||
new Error("Private destinations are not allowed"),
|
new Error("Private destinations are not allowed"),
|
||||||
"",
|
"",
|
||||||
@@ -144,6 +150,7 @@ export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
|||||||
export async function safeOutboundFetch(
|
export async function safeOutboundFetch(
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
|
allowedPrivateHosts: readonly string[] = [],
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
if (
|
if (
|
||||||
@@ -155,13 +162,16 @@ export async function safeOutboundFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||||
if (isIP(hostname) && isBlockedAddress(hostname)) {
|
const allowPrivate = allowedPrivateHosts.some(
|
||||||
|
(host) => host.trim().toLowerCase() === hostname.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!allowPrivate && isIP(hostname) && isBlockedAddress(hostname)) {
|
||||||
throw new Error("Private destinations are not allowed");
|
throw new Error("Private destinations are not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatcher = new Agent({
|
const dispatcher = new Agent({
|
||||||
connect: {
|
connect: {
|
||||||
lookup: createDnsLookupHook(lookup),
|
lookup: createDnsLookupHook(lookup, allowPrivate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -216,3 +216,24 @@ export async function setAiPrivateEndpoints(
|
|||||||
throw handleApiError(error, "update AI endpoint allowlist");
|
throw handleApiError(error, "update AI endpoint allowlist");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return (await authApi.get("/users/notification-private-endpoints")).data
|
||||||
|
.hosts;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get notification endpoint allowlist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNotificationPrivateEndpoints(
|
||||||
|
hosts: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await authApi.patch("/users/notification-private-endpoints", { hosts })
|
||||||
|
).data.hosts;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update notification endpoint allowlist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3595,6 +3595,9 @@
|
|||||||
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
|
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
|
||||||
"aiPrivateEndpoints": "Allowed private AI hosts",
|
"aiPrivateEndpoints": "Allowed private AI hosts",
|
||||||
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
|
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
|
||||||
|
"notificationPrivateEndpoints": "Allowed private notification hosts",
|
||||||
|
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
||||||
|
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
||||||
"updateAiEnabledFailed": "Could not update the AI setting",
|
"updateAiEnabledFailed": "Could not update the AI setting",
|
||||||
"updateAiEndpointsFailed": "Could not update the allowed hosts"
|
"updateAiEndpointsFailed": "Could not update the allowed hosts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { notifyAiStatusChanged } from "@/hooks/use-ai-availability";
|
|||||||
import {
|
import {
|
||||||
getAiGloballyEnabled,
|
getAiGloballyEnabled,
|
||||||
getAiPrivateEndpoints,
|
getAiPrivateEndpoints,
|
||||||
|
getNotificationPrivateEndpoints,
|
||||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||||
|
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
|
||||||
} from "@/api/ai-api";
|
} from "@/api/ai-api";
|
||||||
import {
|
import {
|
||||||
getUserList,
|
getUserList,
|
||||||
@@ -169,6 +171,8 @@ export function AdminSettingsPanel({
|
|||||||
useState(true);
|
useState(true);
|
||||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
||||||
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
|
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
|
||||||
|
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||||
|
useState<string[]>([]);
|
||||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||||
const [touchInputSettings, setTouchInputSettings] =
|
const [touchInputSettings, setTouchInputSettings] =
|
||||||
useState<TouchInputSettings>({ ...TOUCH_INPUT_DEFAULTS });
|
useState<TouchInputSettings>({ ...TOUCH_INPUT_DEFAULTS });
|
||||||
@@ -365,6 +369,7 @@ export function AdminSettingsPanel({
|
|||||||
touchInput,
|
touchInput,
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
aiEndpoints,
|
aiEndpoints,
|
||||||
|
notificationEndpoints,
|
||||||
imageStorage,
|
imageStorage,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
getRegistrationAllowed(),
|
getRegistrationAllowed(),
|
||||||
@@ -383,6 +388,7 @@ export function AdminSettingsPanel({
|
|||||||
getTouchInputSettings(),
|
getTouchInputSettings(),
|
||||||
getAiGloballyEnabled(),
|
getAiGloballyEnabled(),
|
||||||
getAiPrivateEndpoints(),
|
getAiPrivateEndpoints(),
|
||||||
|
getNotificationPrivateEndpoints(),
|
||||||
getTerminalImageStorageSettings(),
|
getTerminalImageStorageSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -435,6 +441,9 @@ export function AdminSettingsPanel({
|
|||||||
if (aiEndpoints.status === "fulfilled") {
|
if (aiEndpoints.status === "fulfilled") {
|
||||||
setAiPrivateEndpoints(aiEndpoints.value);
|
setAiPrivateEndpoints(aiEndpoints.value);
|
||||||
}
|
}
|
||||||
|
if (notificationEndpoints.status === "fulfilled") {
|
||||||
|
setNotificationPrivateEndpoints(notificationEndpoints.value);
|
||||||
|
}
|
||||||
if (imageStorage.status === "fulfilled") {
|
if (imageStorage.status === "fulfilled") {
|
||||||
setImageStorageSettings(imageStorage.value);
|
setImageStorageSettings(imageStorage.value);
|
||||||
}
|
}
|
||||||
@@ -594,6 +603,19 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
|
||||||
|
const previous = notificationPrivateEndpoints;
|
||||||
|
setNotificationPrivateEndpoints(hosts);
|
||||||
|
try {
|
||||||
|
setNotificationPrivateEndpoints(
|
||||||
|
await setNotificationPrivateEndpointsApi(hosts),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setNotificationPrivateEndpoints(previous);
|
||||||
|
toast.error(t("admin.updateNotificationEndpointsFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTouchInputSettings(settings = touchInputSettings) {
|
async function saveTouchInputSettings(settings = touchInputSettings) {
|
||||||
try {
|
try {
|
||||||
const saved = await updateTouchInputSettings(settings);
|
const saved = await updateTouchInputSettings(settings);
|
||||||
@@ -1119,6 +1141,10 @@ export function AdminSettingsPanel({
|
|||||||
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
|
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
|
||||||
aiPrivateEndpoints={aiPrivateEndpoints}
|
aiPrivateEndpoints={aiPrivateEndpoints}
|
||||||
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
||||||
|
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||||
|
onSaveNotificationPrivateEndpoints={
|
||||||
|
handleSaveNotificationPrivateEndpoints
|
||||||
|
}
|
||||||
handleToggleSessionSharingGloballyEnabled={
|
handleToggleSessionSharingGloballyEnabled={
|
||||||
handleToggleSessionSharingGloballyEnabled
|
handleToggleSessionSharingGloballyEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type GeneralSettingsSectionProps = {
|
|||||||
onToggleAiGloballyEnabled: () => void;
|
onToggleAiGloballyEnabled: () => void;
|
||||||
aiPrivateEndpoints: string[];
|
aiPrivateEndpoints: string[];
|
||||||
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
||||||
|
notificationPrivateEndpoints: string[];
|
||||||
|
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
|
||||||
handleToggleSessionSharingGloballyEnabled: () => void;
|
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
handleToggleRegistration: () => void;
|
handleToggleRegistration: () => void;
|
||||||
@@ -80,6 +82,8 @@ export function AdminGeneralSettingsSection({
|
|||||||
onToggleAiGloballyEnabled,
|
onToggleAiGloballyEnabled,
|
||||||
aiPrivateEndpoints,
|
aiPrivateEndpoints,
|
||||||
onSaveAiPrivateEndpoints,
|
onSaveAiPrivateEndpoints,
|
||||||
|
notificationPrivateEndpoints,
|
||||||
|
onSaveNotificationPrivateEndpoints,
|
||||||
handleToggleSessionSharingGloballyEnabled,
|
handleToggleSessionSharingGloballyEnabled,
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
handleToggleRegistration,
|
handleToggleRegistration,
|
||||||
@@ -184,6 +188,27 @@ export function AdminGeneralSettingsSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5 py-2">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{t("admin.notificationPrivateEndpoints")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{t("admin.notificationPrivateEndpointsDesc")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="rounded-none"
|
||||||
|
defaultValue={notificationPrivateEndpoints.join(", ")}
|
||||||
|
placeholder="ntfy.internal, 192.168.1.20"
|
||||||
|
onBlur={(event) =>
|
||||||
|
onSaveNotificationPrivateEndpoints(
|
||||||
|
event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("admin.allowRegistration")}
|
label={t("admin.allowRegistration")}
|
||||||
description={t("admin.allowRegistrationDesc")}
|
description={t("admin.allowRegistrationDesc")}
|
||||||
|
|||||||
Reference in New Issue
Block a user