Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0501c6f213 | ||
|
|
8bf8c5e0ea | ||
|
|
43a57a2dad | ||
|
|
7859faaa1d | ||
|
|
bff6ec735e | ||
|
|
72b075649e | ||
|
|
c18548f7c2 | ||
|
|
1fbe844598 | ||
|
|
365ef7f02b | ||
|
|
2b1a03849d | ||
|
|
4c243bc374 | ||
|
|
ecd02f73c0 | ||
|
|
cd23b36f3c | ||
|
|
f7f29eade9 | ||
|
|
32a51c1e60 | ||
|
|
da32acb4f0 | ||
|
|
1da13d9bb3 | ||
|
|
cb0fec088f | ||
|
|
a2fe8dd461 | ||
|
|
b6dfcd69ba | ||
|
|
a38b0ef71d | ||
|
|
2cf3027eeb | ||
|
|
fa18f6fc71 | ||
|
|
331925c197 | ||
|
|
0e51f745d3 | ||
|
|
d6f720d0e6 | ||
|
|
5324bf12d4 | ||
|
|
2922a02ecf | ||
|
|
87cdcf28e0 | ||
|
|
441b181e79 | ||
|
|
eba639b59c | ||
|
|
da36283ad1 | ||
|
|
c050368782 | ||
|
|
82b50f0fd7 | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d | ||
|
|
aca3b5d861 | ||
|
|
a7a2f0223a | ||
|
|
7773fdc10b | ||
|
|
7019a1d0a4 | ||
|
|
2d535caeac | ||
|
|
2013fbe80d | ||
|
|
219ef58926 | ||
|
|
97195bc463 | ||
|
|
73a742154e | ||
|
|
993a267e0c | ||
|
|
f1c49d1fbf | ||
|
|
95c66c61eb | ||
|
|
ec2f8c6c2c | ||
|
|
05ba57c2fa | ||
|
|
ddbf05524b | ||
|
|
df438b4cd2 | ||
|
|
a1f91eb385 | ||
|
|
8d5ffb2dd9 | ||
|
|
56e6b23af6 | ||
|
|
5488bf4d18 | ||
|
|
621ea07549 | ||
|
|
f64c01456b | ||
|
|
56198c3f02 | ||
|
|
ca1bb6f341 | ||
|
|
bf6b6663cd | ||
|
|
5c2d86767f | ||
|
|
7c75642d4d | ||
|
|
7652eb8c1c | ||
|
|
e761c10f32 | ||
|
|
f6137b2dcc | ||
|
|
425dd90871 |
No files matched your search
@@ -24,14 +24,12 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
|
||||
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
|
||||
|
||||
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
|
||||
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
@@ -42,7 +40,7 @@ Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -70,37 +70,6 @@ through MCP.
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
|
||||
## Authenticated integration requests
|
||||
|
||||
`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials.
|
||||
Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET
|
||||
endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation.
|
||||
|
||||
Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract.
|
||||
For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3):
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
|
||||
```
|
||||
|
||||
Deleting series 42 **and its episode files**, after confirmation:
|
||||
|
||||
```json
|
||||
{ "integrationId": "<Sonarr ID>", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true }
|
||||
```
|
||||
|
||||
Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET).
|
||||
Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses.
|
||||
Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a
|
||||
1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions.
|
||||
|
||||
Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to
|
||||
the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected.
|
||||
|
||||
Supported integrations: Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd,
|
||||
Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud. Other authentication schemes
|
||||
are rejected. The built-in Assistant's default mutation approval applies to this tool, including GET calls.
|
||||
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
@@ -26,7 +25,6 @@ interface Props {
|
||||
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useI18n("section.container");
|
||||
const tSection = useI18n("section");
|
||||
@@ -41,26 +39,8 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
let labelLeft = 8;
|
||||
let labelRight = 8;
|
||||
if (isEditMode) {
|
||||
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
|
||||
labelLeft = 36;
|
||||
if (parentSection.kind === "container") labelLeft = 68;
|
||||
} else if (options.showOpenAll) {
|
||||
labelRight = 40;
|
||||
}
|
||||
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
|
||||
const toggleLayout = isVisuallyCollapsed
|
||||
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
|
||||
: {
|
||||
top: "calc(var(--mantine-spacing-xs) * -1)",
|
||||
left: labelLeft,
|
||||
w: "auto",
|
||||
h: 20,
|
||||
maw: `calc(100% - ${labelLeft + labelRight}px)`,
|
||||
};
|
||||
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
|
||||
const labelLeft = 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
@@ -88,14 +68,24 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
{...toggleLayout}
|
||||
px={6}
|
||||
ps={isVisuallyCollapsed ? labelLeft : 6}
|
||||
pe={isVisuallyCollapsed ? labelRight : 6}
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={options.showLabel && toggleIcon}
|
||||
leftSection={
|
||||
options.showLabel ? (
|
||||
isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
@@ -104,7 +94,13 @@ export const BoardContainerSection = ({ section }: Props) => {
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{options.showLabel ? label : toggleIcon}
|
||||
{options.showLabel ? (
|
||||
label
|
||||
) : isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
|
||||
|
||||
@@ -133,21 +133,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-type="section"] {
|
||||
clip-path: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
|
||||
position: absolute;
|
||||
inset: var(--board-container-inset, 0px);
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
clip-path: inset(var(--board-grid-item-inset, 0px));
|
||||
|
||||
@@ -136,7 +136,6 @@ export const GridPreviewLayer = ({
|
||||
style={getLogicalItemStyle(targetPlacement)}
|
||||
data-grid-placeholder-for={interaction?.activeId}
|
||||
data-grid-placeholder-mode={interaction?.mode}
|
||||
data-grid-placeholder-type={targetPlacement.type}
|
||||
data-grid-x={targetPlacement.x}
|
||||
data-grid-y={targetPlacement.y}
|
||||
data-grid-w={targetPlacement.w}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
while growing during a drag happening anywhere else on the board - any container (not just
|
||||
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
|
||||
inside that section's DOM tree, so the stray value cascades straight through and silently
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
|
||||
kicks in when the property is entirely unset, not merely inherited. Reset it here so every
|
||||
container can never inherit a stray
|
||||
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
|
||||
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
|
||||
unset, not merely inherited. Reset it here so every container can never inherit a stray
|
||||
value from anywhere above it, no matter what any ancestor's own drag state is doing.
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
|
||||
.viewport[data-section-kind="container"] {
|
||||
@@ -62,9 +62,9 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* SectionGrid fits its content to the same inset in both view and edit mode. */
|
||||
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
|
||||
.staticItem[data-type="section"] > .contentMount {
|
||||
inset: var(--board-container-inset, 0px);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.staticItem[data-type="item"] > .contentMount {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -29,9 +29,6 @@ import { useSectionItems } from "../use-section-items";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
const GridContentScaleContext = createContext(1);
|
||||
const CONTAINER_CARD_INSET = 5;
|
||||
|
||||
interface SectionGridProps {
|
||||
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
|
||||
columnCount: number;
|
||||
@@ -53,7 +50,6 @@ export const SectionGrid = ({
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const parentContentScale = useContext(GridContentScaleContext);
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const editorRegistry = useGridEditorRegistry();
|
||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -147,37 +143,36 @@ export const SectionGrid = ({
|
||||
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
|
||||
const viewportRowCount =
|
||||
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
|
||||
const parentScale = canvasScale * parentContentScale;
|
||||
const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
|
||||
// Match the card's inset without changing its persisted grid footprint. Include ancestor
|
||||
// container zoom so equally sized nested containers keep distinct borders at every depth.
|
||||
let outerCardInset = 0;
|
||||
if (section.kind === "container") {
|
||||
outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
|
||||
}
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
|
||||
// Convert that height so the label reserves the same space at every board scale.
|
||||
const collapsibleHeaderInset =
|
||||
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
|
||||
const fullGridWidth = getLogicalGridSize(columnCount);
|
||||
const fullGridHeight = getLogicalGridSize(rowCount);
|
||||
const logicalWidth = fullGridWidth;
|
||||
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
|
||||
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
|
||||
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
|
||||
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
|
||||
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
|
||||
// describe the real visible card size in both cases (they equal the non-scrollable values when
|
||||
// rowCount and viewportRowCount are the same), so use those instead.
|
||||
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
|
||||
const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
|
||||
const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
|
||||
// Fit square cells into the inset card. Scrollable grids use the visible row count,
|
||||
// not their full content height. Collapse controls never change this geometry.
|
||||
let containerContentScale = 1;
|
||||
if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
|
||||
containerContentScale = Math.max(
|
||||
0.01,
|
||||
Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
|
||||
);
|
||||
}
|
||||
const contentScale = parentContentScale * containerContentScale;
|
||||
const effectiveContentScale = effectiveCanvasScale * containerContentScale;
|
||||
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
|
||||
// nested content uniformly scaled within the remaining height so labels and icons retain their
|
||||
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
|
||||
const containerContentScale =
|
||||
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
|
||||
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
|
||||
: 1;
|
||||
// Compute the combined value in JS rather than a CSS calc() referencing the existing
|
||||
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
|
||||
// sequential/temporal order the way normal variables do, so any calc() on this element that
|
||||
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
|
||||
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
|
||||
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
|
||||
const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
|
||||
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
|
||||
// A collapsed container's compact coordinates are display-only. Its own
|
||||
// nested grid stays inactive until an explicit edit interaction expands it.
|
||||
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
|
||||
@@ -273,9 +268,9 @@ export const SectionGrid = ({
|
||||
{
|
||||
width: logicalWidth,
|
||||
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
|
||||
marginTop: collapsibleHeaderInset || undefined,
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
"--board-grid-content-scale": containerContentScale,
|
||||
"--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-section-id={section.id}
|
||||
@@ -294,17 +289,14 @@ export const SectionGrid = ({
|
||||
height: fullGridHeight,
|
||||
zoom: containerContentScale,
|
||||
margin: containerContentScale < 1 ? "0 auto" : undefined,
|
||||
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
|
||||
"--board-canvas-ui-scale": combinedUiScale,
|
||||
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
|
||||
} as CSSProperties
|
||||
}
|
||||
data-grid-section-id={section.id}
|
||||
data-kind={section.kind}
|
||||
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
|
||||
>
|
||||
<GridContentScaleContext.Provider value={contentScale}>
|
||||
<SectionContent />
|
||||
</GridContentScaleContext.Provider>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
<div ref={editorHostRef} className={classes.editorPortalHost} />
|
||||
</Box>
|
||||
@@ -312,6 +304,9 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Matches the collapsible container toggle's h={24} in container-section.tsx.
|
||||
const CONTAINER_HEADER_HEIGHT = 24;
|
||||
|
||||
const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
||||
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { protectedProcedure } from "../../trpc";
|
||||
|
||||
export const integrationRequestSchema = z.object({
|
||||
integrationId: z.string().min(1).max(128),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1).max(8192),
|
||||
body: z.json().optional(),
|
||||
confirmed: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Required for DELETE after the user confirms the deletion and its effects"),
|
||||
});
|
||||
|
||||
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
|
||||
// Reject parser normalization tricks as well as ordinary absolute URLs.
|
||||
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
|
||||
}
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
const target = new URL(path, base);
|
||||
if (
|
||||
!["http:", "https:"].includes(base.protocol) ||
|
||||
base.username ||
|
||||
base.password ||
|
||||
target.origin !== base.origin ||
|
||||
target.username ||
|
||||
target.password ||
|
||||
target.hash
|
||||
)
|
||||
throw new Error();
|
||||
return target;
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "path must resolve to the integration origin without credentials or a fragment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Match the existing adapters; never guess authentication from secret names alone.
|
||||
const requestAuth = {
|
||||
sonarr: "apiKeyHeader",
|
||||
radarr: "apiKeyHeader",
|
||||
lidarr: "apiKeyHeader",
|
||||
readarr: "apiKeyHeader",
|
||||
prowlarr: "apiKeyHeader",
|
||||
overseerr: "apiKeyHeader",
|
||||
jellyseerr: "apiKeyHeader",
|
||||
seerr: "apiKeyHeader",
|
||||
immich: "apiKeyHeader",
|
||||
slskd: "apiKeyHeader",
|
||||
homeAssistant: "bearer",
|
||||
coolify: "bearer",
|
||||
audiobookshelf: "bearer",
|
||||
speedtestTracker: "bearer",
|
||||
adGuardHome: "basic",
|
||||
nextcloud: "basic",
|
||||
} satisfies Partial<Record<IntegrationKind, string>>;
|
||||
|
||||
export function resolveIntegrationRequestAuth(
|
||||
kind: IntegrationKind,
|
||||
secrets: CustomWidgetAuthConfig["secrets"],
|
||||
): CustomWidgetAuthConfig {
|
||||
if (!(kind in requestAuth)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This integration's authentication is not supported by integration_request",
|
||||
});
|
||||
}
|
||||
const type = requestAuth[kind as keyof typeof requestAuth];
|
||||
let required = ["apiKey"];
|
||||
if (type === "basic") required = ["username", "password"];
|
||||
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
|
||||
}
|
||||
return { type, secrets };
|
||||
}
|
||||
|
||||
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
|
||||
const values = secrets.map(({ value }) => value).filter(Boolean);
|
||||
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
||||
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
||||
if (username && password) values.push(`${username}:${password}`);
|
||||
const sensitive = [
|
||||
...new Set(
|
||||
values.flatMap((value) => [
|
||||
value,
|
||||
JSON.stringify(value).slice(1, -1),
|
||||
encodeURIComponent(value),
|
||||
Buffer.from(value).toString("base64"),
|
||||
]),
|
||||
),
|
||||
].toSorted((a, b) => b.length - a.length);
|
||||
const redact = (value: string) => {
|
||||
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
|
||||
return value;
|
||||
};
|
||||
const visit = (value: unknown): unknown => {
|
||||
if (typeof value === "string") return redact(value);
|
||||
if (
|
||||
(typeof value === "number" || typeof value === "boolean" || value === null) &&
|
||||
redact(String(value)) !== String(value)
|
||||
)
|
||||
return "[REDACTED]";
|
||||
if (Array.isArray(value)) return value.map(visit);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return visit(data);
|
||||
}
|
||||
|
||||
export const integrationRequestProcedure = protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
|
||||
},
|
||||
})
|
||||
.input(integrationRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.integrationId),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groups.map(({ groupId }) => groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Arbitrary GET endpoints can expose service credentials or mutate state.
|
||||
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
|
||||
}
|
||||
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
|
||||
if (input.method === "DELETE" && input.confirmed !== true) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
|
||||
}
|
||||
if (input.method === "GET" && input.body !== undefined) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
|
||||
}
|
||||
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
|
||||
// Do not log upstream data or retain error causes.
|
||||
try {
|
||||
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
|
||||
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
|
||||
let tls: CustomWidgetHttpRequest["tls"];
|
||||
if (targetUrl.protocol === "https:") {
|
||||
const [ca, hostnames] = await Promise.all([
|
||||
getAllTrustedCertificatesAsync(),
|
||||
getTrustedCertificateHostnamesAsync(),
|
||||
]);
|
||||
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
||||
}
|
||||
const response = await executeCustomWidgetRequest({
|
||||
baseUrl: integration.url,
|
||||
targetUrl,
|
||||
method: input.method,
|
||||
body: JSON.stringify(input.body),
|
||||
auth,
|
||||
tls,
|
||||
networkScope: "loopback",
|
||||
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
|
||||
timeoutMs: 15_000,
|
||||
textFallback: true,
|
||||
});
|
||||
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
|
||||
if (error instanceof CustomWidgetDomainError) {
|
||||
throw new TRPCError({
|
||||
code: error.code,
|
||||
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
|
||||
}
|
||||
});
|
||||
@@ -42,7 +42,6 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { integrationRequestProcedure } from "./integration-request";
|
||||
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
@@ -50,7 +49,6 @@ const logger = createLogger({ module: "integrationRouter" });
|
||||
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
request: integrationRequestProcedure,
|
||||
getKinds: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y
|
||||
MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG
|
||||
ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO
|
||||
Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud
|
||||
DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp
|
||||
KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0
|
||||
aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3
|
||||
r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,5 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly
|
||||
cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA
|
||||
NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,300 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
|
||||
import { eq } from "@homarr/db";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import type { IntegrationPermission } from "@homarr/definitions";
|
||||
|
||||
import { createTRPCRouter } from "../trpc";
|
||||
import {
|
||||
integrationRequestProcedure,
|
||||
integrationRequestSchema,
|
||||
redactIntegrationResponse,
|
||||
resolveIntegrationRequestAuth,
|
||||
resolveIntegrationRequestUrl,
|
||||
} from "../router/integration/integration-request";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
const trust = vi.hoisted(() => ({
|
||||
certificates: vi.fn(async (): Promise<string[]> => []),
|
||||
hostnames: vi.fn(async (): Promise<{ hostname: string; thumbprint: string }[]> => []),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: trust.certificates,
|
||||
getTrustedCertificateHostnamesAsync: trust.hostnames,
|
||||
}));
|
||||
|
||||
const router = createTRPCRouter({ request: integrationRequestProcedure });
|
||||
const secret = "test-credential-not-for-output";
|
||||
const calls: { method: string | undefined; path: string | undefined; auth: string | undefined; body: string }[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += String(chunk);
|
||||
calls.push({ method: req.method, path: req.url, auth: req.headers["x-api-key"] as string | undefined, body });
|
||||
if (req.url === "/slow") return;
|
||||
if (req.url === "/redirect") {
|
||||
res.writeHead(302, { Location: "/echo" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.url === "/large") {
|
||||
res.end("x".repeat(1024 * 1024 + 1));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/echo") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ [secret]: [secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")] }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/text") {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(`invalid JSON ${secret}`);
|
||||
return;
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify([{ id: 42, title: "Example series" }]));
|
||||
});
|
||||
let baseUrl: string;
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType<typeof createDb>) => Promise<void>) {
|
||||
const db = createDb();
|
||||
await db.insert(users).values([{ id: "reader" }, { id: "other" }]);
|
||||
await db.insert(integrations).values({ id: "sonarr", name: "Sonarr", kind: "sonarr", url: baseUrl });
|
||||
await db.insert(integrationSecrets).values({ integrationId: "sonarr", kind: "apiKey", value: encryptSecret(secret) });
|
||||
// Another user's full permission must never grant this caller access.
|
||||
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "other", permission: "full" });
|
||||
if (permission)
|
||||
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "reader", permission });
|
||||
const session = {
|
||||
user: { id: "reader", permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
await setup?.(db);
|
||||
return router.createCaller({ db, session, deviceType: undefined });
|
||||
}
|
||||
|
||||
const get = { integrationId: "sonarr", method: "GET", path: "/api/v3/series" } as const;
|
||||
|
||||
describe("integration_request validation", () => {
|
||||
test.each([
|
||||
"https://evil.com/x",
|
||||
"https://example.com/x",
|
||||
"//evil.com/x",
|
||||
"\\\\evil.com/x",
|
||||
"/\\evil.com",
|
||||
" https://evil.com",
|
||||
"https:\nevil.com",
|
||||
"/x#fragment",
|
||||
])("rejects %s", (path) => {
|
||||
expect(() => resolveIntegrationRequestUrl("https://example.com/base/", path)).toThrow();
|
||||
});
|
||||
test.each(["../series", "../../series", "%2e%2e/series"])("normalizes same-origin traversal %s", (path) => {
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", path).href).toBe("https://example.com/series");
|
||||
});
|
||||
test("preserves URL resolution semantics and query parameters", () => {
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", "series?id=42").href).toBe(
|
||||
"https://example.com/base/series?id=42",
|
||||
);
|
||||
expect(resolveIntegrationRequestUrl("https://example.com/base/", "/series").pathname).toBe("/series");
|
||||
});
|
||||
test("accepts only JSON bodies and the specified methods", () => {
|
||||
expect(integrationRequestSchema.safeParse({ ...get, body: new Date() }).success).toBe(false);
|
||||
expect(integrationRequestSchema.safeParse({ ...get, method: "HEAD" }).success).toBe(false);
|
||||
});
|
||||
test("resolves supported auth and rejects unsupported or incomplete credentials", () => {
|
||||
expect(resolveIntegrationRequestAuth("sonarr", [{ kind: "apiKey", value: secret }]).type).toBe("apiKeyHeader");
|
||||
expect(resolveIntegrationRequestAuth("homeAssistant", [{ kind: "apiKey", value: secret }]).type).toBe("bearer");
|
||||
expect(
|
||||
resolveIntegrationRequestAuth("adGuardHome", [
|
||||
{ kind: "username", value: "user" },
|
||||
{ kind: "password", value: secret },
|
||||
]).type,
|
||||
).toBe("basic");
|
||||
expect(() => resolveIntegrationRequestAuth("qBittorrent", [])).toThrow("not supported");
|
||||
expect(() => resolveIntegrationRequestAuth("sonarr", [])).toThrow("incomplete");
|
||||
});
|
||||
test.each([42, true, false, null])("redacts echoed primitive credential %s", (value) => {
|
||||
expect(redactIntegrationResponse(value, [{ kind: "password", value: String(value) }])).toBe("[REDACTED]");
|
||||
});
|
||||
test("redacts JSON-escaped credentials embedded in text", () => {
|
||||
const password = 'quoted"password\\with-newline\n';
|
||||
const encoded = JSON.stringify(password).slice(1, -1);
|
||||
expect(
|
||||
redactIntegrationResponse({ message: `password=${encoded}` }, [{ kind: "password", value: password }]),
|
||||
).toEqual({ message: "password=[REDACTED]" });
|
||||
});
|
||||
test("redacts basic credentials and JSON keys without damaging JSON", () => {
|
||||
const credentials = Buffer.from(`user:${secret}`).toString("base64");
|
||||
expect(
|
||||
redactIntegrationResponse({ [secret]: `Basic ${credentials}` }, [
|
||||
{ kind: "username", value: "user" },
|
||||
{ kind: "password", value: secret },
|
||||
]),
|
||||
).toEqual({ "[REDACTED]": "Basic [REDACTED]" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration_request with a mock integration server", () => {
|
||||
test("GET authenticates with stored credentials and full permission", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: [{ id: 42, title: "Example series" }] });
|
||||
expect(calls.at(-1)).toMatchObject({ method: "GET", path: "/api/v3/series", auth: secret });
|
||||
});
|
||||
test("keeps credentials and URL from the same integration snapshot", async () => {
|
||||
const rotated = "credential-for-the-new-origin";
|
||||
const caller = await fixture("full", async (db) => {
|
||||
const snapshot = await db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, "sonarr"),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, "reader") },
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
// Simulate a URL/key rotation immediately after the first SELECT completed.
|
||||
await db.update(integrations).set({ url: "https://new-origin.example" }).where(eq(integrations.id, "sonarr"));
|
||||
await db.update(integrationSecrets).set({ value: encryptSecret(rotated) });
|
||||
vi.spyOn(db.query.integrations, "findFirst").mockResolvedValueOnce(snapshot);
|
||||
});
|
||||
await caller.request(get);
|
||||
expect(calls.at(-1)?.auth).toBe(secret);
|
||||
});
|
||||
test("uses Homarr's trusted certificates and hostname exceptions without disabling TLS checks", async () => {
|
||||
// This committed certificate/key pair is only for the loopback mock server.
|
||||
const cert = readFileSync(new URL("./fixtures/integration-request-cert.pem", import.meta.url), "utf8");
|
||||
const key = readFileSync(new URL("./fixtures/integration-request-key.pem", import.meta.url), "utf8");
|
||||
const https = createHttpsServer({ cert, key }, (_req, res) => res.end('{"trusted":true}'));
|
||||
await new Promise<void>((resolve) => https.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const caller = await fixture("full", async (db) => {
|
||||
await db.update(integrations).set({ url: `https://127.0.0.1:${(https.address() as AddressInfo).port}` });
|
||||
});
|
||||
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
trust.certificates.mockResolvedValue([cert]);
|
||||
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
trust.hostnames.mockResolvedValue([
|
||||
{ hostname: "127.0.0.1", thumbprint: new X509Certificate(cert).fingerprint256 },
|
||||
]);
|
||||
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: { trusted: true } });
|
||||
} finally {
|
||||
trust.certificates.mockResolvedValue([]);
|
||||
trust.hostnames.mockResolvedValue([]);
|
||||
https.closeAllConnections();
|
||||
await new Promise<void>((resolve) => https.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
test("denies GET without full permission even when another user has full access", async () => {
|
||||
const caller = await fixture();
|
||||
const before = calls.length;
|
||||
await expect(caller.request(get)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)(
|
||||
"denies %s with only use permission",
|
||||
async (method) => {
|
||||
const caller = await fixture("use");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, method, confirmed: true })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
expect(calls).toHaveLength(before);
|
||||
},
|
||||
);
|
||||
test("interact access does not authorize arbitrary endpoints", async () => {
|
||||
const caller = await fixture("interact");
|
||||
await expect(caller.request(get)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Missing integration permission: hasFullAccess",
|
||||
});
|
||||
});
|
||||
test("DELETE requires confirmation and preserves deleteFiles", async () => {
|
||||
const caller = await fixture("full");
|
||||
const input = { ...get, method: "DELETE", path: "/api/v3/series/42?deleteFiles=true" } as const;
|
||||
const before = calls.length;
|
||||
await expect(caller.request(input)).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
|
||||
expect(calls).toHaveLength(before);
|
||||
await expect(caller.request({ ...input, confirmed: true })).resolves.toEqual({ status: 204, data: null });
|
||||
expect(calls.at(-1)).toMatchObject({ method: "DELETE", path: input.path, auth: secret });
|
||||
});
|
||||
test("sends a JSON body with full access", async () => {
|
||||
const caller = await fixture("full");
|
||||
await caller.request({ ...get, method: "PATCH", body: { monitored: false } });
|
||||
expect(calls.at(-1)?.body).toBe('{"monitored":false}');
|
||||
});
|
||||
test.each(["https://evil.com/x", "//evil.com/x"])("rejects %s before HTTP", async (path) => {
|
||||
const caller = await fixture("full");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, path })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(calls).toHaveLength(before);
|
||||
});
|
||||
test("never follows a redirect", async () => {
|
||||
const caller = await fixture("full");
|
||||
const before = calls.length;
|
||||
await expect(caller.request({ ...get, path: "/redirect" })).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
expect(calls).toHaveLength(before + 1);
|
||||
});
|
||||
test("enforces the response and request caps", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request({ ...get, path: "/large" })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
|
||||
await expect(caller.request({ ...get, method: "POST", body: "x".repeat(10 * 1024) })).rejects.toMatchObject({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
test("redacts upstream JSON and text, including error responses", async () => {
|
||||
const caller = await fixture("full");
|
||||
await expect(caller.request({ ...get, path: "/echo" })).resolves.toEqual({
|
||||
status: 400,
|
||||
data: { "[REDACTED]": ["[REDACTED]", "[REDACTED]", "[REDACTED]"] },
|
||||
});
|
||||
await expect(caller.request({ ...get, path: "/text" })).resolves.toEqual({
|
||||
status: 500,
|
||||
data: "invalid JSON [REDACTED]",
|
||||
});
|
||||
});
|
||||
test("honors the executor's total deadline without logging credentials", async () => {
|
||||
const logError = vi.fn();
|
||||
await expect(
|
||||
executeCustomWidgetRequest({
|
||||
baseUrl,
|
||||
targetUrl: `${baseUrl}/slow`,
|
||||
method: "GET",
|
||||
networkScope: "loopback",
|
||||
kind: "action",
|
||||
timeoutMs: 30,
|
||||
auth: { type: "apiKeyHeader", secrets: [{ kind: "apiKey", value: secret }] },
|
||||
logError,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_GATEWAY", reason: "timeout" });
|
||||
expect(JSON.stringify(logError.mock.calls)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,6 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"wud_getStats",
|
||||
],
|
||||
mutation: [
|
||||
"integration_request",
|
||||
"app_create",
|
||||
"app_delete",
|
||||
"app_update",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope } from "../core";
|
||||
@@ -192,11 +191,7 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
|
||||
}
|
||||
}
|
||||
|
||||
export function createPinnedAgent(
|
||||
addresses: ResolvedAddress[],
|
||||
timeoutMs: number,
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
|
||||
) {
|
||||
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
||||
@@ -209,7 +204,7 @@ export function createPinnedAgent(
|
||||
else callback(null, selected.address, selected.family);
|
||||
};
|
||||
return new Agent({
|
||||
connect: { ...tls, lookup: customLookup },
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Headers, Response } from "undici";
|
||||
|
||||
@@ -53,9 +52,6 @@ export interface CustomWidgetHttpRequest {
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
timeoutMs?: number;
|
||||
textFallback?: boolean;
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
|
||||
@@ -77,10 +73,7 @@ type RequestHopResult =
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
|
||||
);
|
||||
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
||||
try {
|
||||
return await performRequestWithinDeadline(input, controller.signal);
|
||||
} catch (error) {
|
||||
@@ -120,7 +113,6 @@ async function performRequestWithinDeadline(
|
||||
const dispatcher = createPinnedAgent(
|
||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
input.tls,
|
||||
);
|
||||
const headers = buildHeaders(input, currentUrl, currentBody);
|
||||
const controller = new AbortController();
|
||||
@@ -149,7 +141,7 @@ async function performRequestWithinDeadline(
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: await parseResponseBody(response, input.textFallback),
|
||||
data: await parseResponseBody(response),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
|
||||
export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const text = await readLimitedBody(response);
|
||||
if (!text) return null;
|
||||
try {
|
||||
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response, textFallback = false
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (!textFallback && response.headers.get("content-type")?.toLowerCase().includes("json")) {
|
||||
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
return text;
|
||||
|
||||
@@ -198,7 +198,7 @@ describe("WudIntegration authentication", () => {
|
||||
expect(requestInit?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
test("sends configured Basic auth credentials over an HTTP URL", async () => {
|
||||
test("does not send credentials over a non-HTTPS URL", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleContainersResponse), {
|
||||
status: 200,
|
||||
@@ -209,7 +209,7 @@ describe("WudIntegration authentication", () => {
|
||||
const integration = new WudIntegration({
|
||||
id: "test-wud-http",
|
||||
name: "Test WUD HTTP",
|
||||
url: "http://wud:3000",
|
||||
url: "http://wud.example.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: TEST_USERNAME },
|
||||
@@ -221,7 +221,7 @@ describe("WudIntegration authentication", () => {
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`);
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,16 +238,15 @@ describe("WudIntegration testing endpoint", () => {
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /api/containers responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify(sampleContainersResponse), { status: 200 }));
|
||||
test("returns success when /api/app responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(String(vi.mocked(input.fetchAsync).mock.calls[0]?.[0])).toBe(`${TEST_URL}/api/containers`);
|
||||
});
|
||||
|
||||
test("returns a status-code failure when /api/containers responds non-OK", async () => {
|
||||
test("returns a status-code failure when /api/app responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
@@ -256,7 +255,7 @@ describe("WudIntegration testing endpoint", () => {
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
||||
const response = new Response(JSON.stringify(sampleContainersResponse), { status: 200 });
|
||||
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
||||
const input = makeInput(response);
|
||||
|
||||
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||
|
||||
@@ -14,7 +14,7 @@ const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class WudIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/containers"), {
|
||||
const response = await input.fetchAsync(this.url("/api/app"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
@@ -23,10 +23,10 @@ export class WudIntegration extends Integration {
|
||||
}
|
||||
|
||||
try {
|
||||
await parseWudContainersResponseAsync(response);
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD containers response", {
|
||||
new ParseError("Invalid WUD app response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
@@ -55,6 +55,10 @@ export class WudIntegration extends Integration {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.url("/api/app").protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -99,6 +99,11 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleMultiSelectToggle = () => {
|
||||
if (multiSelectActive) setSelectedAppIds(new Set());
|
||||
setMultiSelectActive((current) => !current);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<FloatingTip
|
||||
@@ -127,6 +132,19 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{multiSelectAvailable && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={multiSelectActive ? "light" : "subtle"}
|
||||
leftSection={<IconListCheck size={16} />}
|
||||
aria-pressed={multiSelectActive}
|
||||
onClick={handleMultiSelectToggle}
|
||||
>
|
||||
{multiSelectActive ? t("app.action.select.cancelMultiple") : t("app.action.select.selectMultiple")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Scrollable Container with App Cards */}
|
||||
|
||||
Reference in new issue
Block a user