Compare commits

..
Author SHA1 Message Date
ajnart 0501c6f213 merge: sync current dev into v2 readiness 2026-09-12 11:00:19 +02:00
ajnart 8bf8c5e0ea Merge remote-tracking branch 'origin/release/v2' into feat/v2-release-readiness 2026-09-12 10:50:31 +02:00
Kushie 43a57a2dad fix(firewall widget): allow content to shrink and scroll (#6818) 2026-09-12 10:49:01 +02:00
Releases Homarr 7859faaa1d chore(release): 1.77.1 [skip ci] 2026-09-11 19:22:36 +00:00
homarr-releases[bot] bff6ec735e chore(release): automatic release v1.77.1 2026-09-11 19:21:19 +00:00
ajnart 72b075649e chore: replace grid fuzzing with regression report context 2026-09-10 20:07:14 +02:00
ajnart c18548f7c2 fix: preserve dev fixes in v2 and add grid fuzzing 2026-09-10 19:58:53 +02:00
Thomas 1fbe844598 fix(settings): move board access control out of the nested form (#6662) 2026-09-10 18:13:56 +02:00
Thomas 365ef7f02b Revert "feat: announce Homarr v2 beta" (#6813) 2026-09-10 16:36:54 +02:00
homarr-releases[bot] 2b1a03849d chore: update bug report template (#6798) 2026-09-06 22:31:26 +02:00
Releases Homarr 4c243bc374 chore(release): 1.77.0 [skip ci] 2026-09-06 19:31:37 +00:00
homarr-releases[bot] ecd02f73c0 chore(release): automatic release v1.77.0 2026-09-06 19:30:29 +00:00
Thomas cd23b36f3c fix(ci): use pinned pnpm for releases (#6796) 2026-09-06 21:18:30 +02:00
homarr-releases[bot] f7f29eade9 chore(release): automatic release v1.77.0 2026-09-06 18:47:01 +00:00
Thomas 32a51c1e60 feat: announce Homarr v2 beta (#6793)
* feat: announce Homarr v2 beta

* chore: refine v2 beta announcement

* feat: expand v2 beta guidance

* chore: refine v2 beta resources

* chore: simplify v2 beta modal

* chore: improve v2 beta feedback links

* fix: keep v2 modal above header

* chore: sharpen v2 beta message

* fix: clear header for v2 modal
2026-09-06 20:36:04 +02:00
homarr-crowdin[bot] da32acb4f0 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-06 01:52:04 +00:00
homarr-update-contributors[bot] 1da13d9bb3 chore: update contributors (#6767)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-09-04 15:58:09 +00:00
homarr-crowdin[bot] cb0fec088f chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-03 02:05:21 +00:00
homarr-crowdin[bot] a2fe8dd461 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-01 02:36:36 +00:00
homarr-update-contributors[bot] b6dfcd69ba chore: update contributors (#6735)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-28 21:54:00 +00:00
homarr-crowdin[bot]andThomas a38b0ef71d chore(lang): updated translations from crowdin (#6732)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-28 14:07:42 +02:00
Thomas 2cf3027eeb Merge pull request #6720 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-28 08:32:40 +02:00
homarr-releases[bot] fa18f6fc71 chore: update bug report template (#6724) 2026-08-28 08:32:03 +02:00
Releases Homarr 331925c197 chore(release): 1.76.2 [skip ci] 2026-08-27 20:09:22 +00:00
Thomas 0e51f745d3 Merge pull request #6723 from homarr-labs/dev
chore(release): automatic release v1.76.2
2026-08-27 22:08:05 +02:00
Thomas d6f720d0e6 Revert "feat(http): set custom User-Agent on outbound integration requests (#6606)" (#6722)
This reverts commit ddbf05524b.
2026-08-27 22:00:23 +02:00
Thomas 5324bf12d4 fix(startup): use stable internal service addresses (#6721) 2026-08-27 21:52:54 +02:00
github-actions[bot] 2922a02ecf docs(helm): update chart installation page 2026-08-27 17:09:27 +00:00
Thomas 87cdcf28e0 fix(mcp): return valid string for void tool results (#6703) 2026-08-27 11:10:37 +02:00
homarr-releases[bot] 441b181e79 chore: update bug report template (#6716)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-08-27 11:08:43 +02:00
Releases Homarr eba639b59c chore(release): 1.76.1 [skip ci] 2026-08-26 16:43:52 +00:00
Thomas Camlong da36283ad1 chore(release): retrigger release pipeline 2026-08-26 18:30:12 +02:00
Thomas Camlong c050368782 chore(release): trigger release pipeline 2026-08-26 18:16:26 +02:00
homarr-releases[bot] 82b50f0fd7 chore(release): automatic release v1.76.1 2026-08-26 15:50:55 +00:00
Thomas a0149d5894 Merge pull request #6673 from homarr-labs/investigate-6672-immich-401 2026-08-22 19:44:03 +02:00
ajnart 8579fcbfc2 test(http,immich): address CodeRabbit review
- guarantee server teardown via try/finally in both wire-level tests
- add tuple-array HeadersInit coverage to the helper suite
2026-08-22 17:21:38 +02:00
ajnart b6f3aba23e test(http,immich): prove User-Agent fix at the wire level
Add two regression tests that fail on v1.76.0 and pass with the fix:
- wire-level test: a Headers instance (the shape @immich/sdk hands over)
  must reach the remote server with all headers intact
- real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync
  against a live local server must deliver x-api-key
2026-08-22 17:16:31 +02:00
ajnart 31e218295d fix(http): preserve SDK headers when injecting User-Agent
The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which
silently drops all headers when the value is a Headers instance. The Immich
SDK passes a Headers object with the merged x-api-key header, so requests
lost their API key and returned 401 (issue #6672). Build a real Headers
instance instead, keeping case-insensitive user-agent handling.
2026-08-22 17:12:43 +02:00
Thomas aca3b5d861 fix: correct Unraid CPU, link context menus, and board resizing (#6510)
Fix Unraid logical CPU averaging, preserve native context menus for app and bookmark widgets in view mode, and prevent scrollbar-width GridStack resize oscillation.
2026-08-22 10:53:26 +02:00
homarr-crowdin[bot] a7a2f0223a chore(lang): updated translations from crowdin (#6596)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-08-22 08:49:51 +02:00
homarr-releases[bot] 7773fdc10b chore: update bug report template (#6655) 2026-08-21 22:14:45 +02:00
Thomas 7019a1d0a4 Merge pull request #6656 from homarr-labs/docs/update-helm-docs 2026-08-21 22:14:26 +02:00
github-actions[bot] 2d535caeac docs(helm): update chart installation page 2026-08-21 17:41:09 +00:00
Releases Homarr 2013fbe80d chore(release): 1.76.0 [skip ci] 2026-08-21 17:20:50 +00:00
homarr-releases[bot] 219ef58926 chore(release): automatic release v1.76.0 2026-08-21 17:19:40 +00:00
homarr-update-contributors[bot] 97195bc463 chore: update contributors (#6649)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-21 12:53:40 +00:00
Danura30082 73a742154e docs: Proxmox certificate obtention instructions (#6613) 2026-08-20 18:51:58 +02:00
Afonso Jorge Ramos 993a267e0c fix(truenas): keep reporting pools without usable dataset space (#6635) 2026-08-20 15:37:10 +02:00
Thomas f1c49d1fbf fix(startup): only chown runtime paths for custom PUID/PGID (#6598) 2026-08-19 08:29:17 +02:00
Thomas 95c66c61eb fix(widgets): stop Health Monitoring system tab firing NOT_FOUND when Proxmox is mixed in (#6623) 2026-08-18 19:56:47 +02:00
Thomas ec2f8c6c2c fix(widgets): match OMV disk temperature via normalized partition names (#6615) (#6619)
OMV reports filesystem devices (sda1) without the /dev/ prefix, so the
partition-suffix normalization never applied and filesystem-to-SMART
matching returned no temperature. Strip partition suffixes from
unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized
matcher in both the health monitoring and system disks widgets.
2026-08-18 19:39:27 +02:00
Thomas 05ba57c2fa chore: migrate cursor rules to portable agent skills + mantine MCP guidance
* chore: migrate cursor rules to portable agent skills and add mantine MCP guidance

Replace Cursor-only .cursor/rules/*.mdc with Agent Skills open-standard
SKILL.md files in .agents/skills/ (canonical), mirrored to .cursor/skills/
and .claude/skills/ so they work across Claude Code, Cursor, Codex, and
OpenCode. Add mantine MCP server guidance to AGENTS.md and un-ignore
.agents/skills/ in .gitignore.

* refactor: single-source agent skills at .agents/skills

Cursor, Codex, and OpenCode all read .agents/skills/ natively, so the
.cursor/skills/ mirror was redundant. Claude Code reads .claude/skills/,
which is now a symlink back to .agents/skills/ so the content lives once.
Deletes .cursor/skills/ and converts .claude/skills/ to a symlink.

* chore: ship mantine MCP server via project-scoped .mcp.json

Declares the keyless @mantine/mcp-server in .mcp.json (Claude Code
project-scope standard, committable) and documents MCP server setup and
the mantine entry in AGENTS.md.

* chore: address coderabbit security-review and markdown lint findings

Add explicit security-review guidance before exposing MCP tools and label
plain-text code fences to satisfy markdownlint MD040.
2026-08-16 16:27:51 +02:00
Thomas ddbf05524b feat(http): set custom User-Agent on outbound integration requests (#6606)
* feat(http): set custom User-Agent on outbound integration requests

Set a default 'User-Agent: Homarr/<version> (+https://homarr.dev)' header
on all outbound HTTP requests made via fetchWithTrustedCertificatesAsync,
so Homarr's integration traffic is distinguishable in reverse-proxy,
WAF, and security-monitoring logs instead of appearing as generic undici.

Closes #6604

* refactor(version): centralize Homarr version in @homarr/version package

Replace fragile deep-relative 'package.json' imports across the monorepo
with a single source of truth: the new @homarr/version leaf package reads
the version once from the root package.json and exports it as VERSION.

Consumers now import by package name incl. the new User-Agent header in
fetchWithTrustedCertificatesAsync (packages/core):
  - core, api (info router), analytics, request-handler (update checker)
This also removes all manually-maintained relative version imports.

* fix(http): normalize User-Agent injection with Headers

Object-spreading fetchOptions.headers breaks when callers pass a Headers
instance (spread yields no entries) or a tuple array (numeric keys), and is
case-sensitive. Build a Headers instance and set the default User-Agent only
when no case-insensitive User-Agent entry exists, via a shared helper in both
fetch paths.

Adds unit tests covering record, Headers, tuple-array, and casing variants.

* revert(version): undo version refactor and tests, keep single-file change

Keep the User-Agent change as a single edit to
packages/core/src/infrastructure/http/request.ts. Remove the @homarr/version
package, the consumer migrations, and the unit tests.
2026-08-16 16:09:15 +02:00
Thomas df438b4cd2 Merge pull request #6571 from homarr-labs/feat/fix-home-assistant-actions 2026-08-16 00:23:14 +02:00
Thomas a1f91eb385 Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space 2026-08-15 20:20:35 +02:00
Thomas 8d5ffb2dd9 feat(paseo): add worktree install script (#6599)
* feat(paseo): add worktree install script

* feat(paseo): inline worktree setup in paseo.json

* feat(paseo): add scripts and teardown to paseo.json

* feat(paseo): expand workspace scripts catalog

* feat(paseo): reset encryption key in worktree env
2026-08-15 12:29:52 +02:00
Thomas Camlong 56e6b23af6 test(e2e): remove flaky credentials onboarding test 2026-08-15 11:05:06 +02:00
Thomas Camlong 5488bf4d18 fix(truenas): use lowercase in operator for dataset query 2026-08-15 01:33:20 +02:00
homarr-crowdin[bot] 621ea07549 chore(lang): updated translations from crowdin (#6553) 2026-08-14 20:39:02 +02:00
homarr-update-contributors[bot] f64c01456b chore: update contributors (#6588) 2026-08-14 20:38:42 +02:00
homarr-releases[bot] 56198c3f02 chore: update bug report template (#6591) 2026-08-14 20:38:26 +02:00
Thomas ca1bb6f341 Merge pull request #6592 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-14 20:38:10 +02:00
github-actions[bot] bf6b6663cd docs(helm): update chart installation page 2026-08-14 18:25:32 +00:00
Releases Homarr 5c2d86767f chore(release): 1.75.0 [skip ci] 2026-08-14 17:45:24 +00:00
homarr-releases[bot] 7c75642d4d chore(release): automatic release v1.75.0 2026-08-14 17:43:56 +00:00
Thomas Camlong 7652eb8c1c preserve smart home toggle response 2026-08-13 17:04:00 +02:00
Thomas Camlong e761c10f32 fix Home Assistant widget actions 2026-08-13 16:21:48 +02:00
ajnart f6137b2dcc fix(truenas): guard empty dataset percentage 2026-08-12 18:24:33 +02:00
ajnart 425dd90871 fix(truenas): report usable dataset space 2026-08-12 18:19:07 +02:00
123 changed files with 488 additions and 1707 deletions

No files matched your search

@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
}
```
`sources.default` is required. HTTP source properties: `name?`, `baseUrl`, `networkScope`, and `auth?`:
The object key `default` is the required source ID; `default` is not a property on a source. Source properties are `name?`, `baseUrl`, `networkScope`, and `auth?`:
```json
{
@@ -40,12 +40,10 @@ interface HomarrCustomWidgetV2 {
}
```
Integration sources use `{"type":"integration","integrationKind":"sonarr"}`. Bind `integrationId` from `integration_all`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions.
Auth is `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. A request defaults to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Set `trigger: "manual"` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use `load: false`.
Auth: `none`, `bearer`, `basic`, `{ "type": "apiKeyHeader", "name": "X-Api-Key" }`, or `{ "type": "apiKeyQuery", "name": "api_key" }`. Requests default to source `default`, kind `query`, method `GET`, trigger `load`, inherited auth, and view permission. Parameterized queries need `trigger: "manual"`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use `load: false`.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
Paths use `{option:name}` and `{param:name}`; query/body references use `{ "$option": "name" }` and `{ "$param": "name" }`. Constants stay primitive (`take: 10`); `$param` is only for manual helpers, never load queries. Names and types are inferred.
Paths bind `{option:name}` and `{param:name}`; query/body references bind `{ "$option": "name" }` and `{ "$param": "name" }`. Use primitive constants (`take: 10`). `$param` is only for manual requests.
Options require `label`, `control`, and `default`. Optional fields: `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, `group`.
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
@@ -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",
],
},
@@ -3,28 +3,10 @@
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`,
`basic`, an API-key header, or an API-key query parameter. HTTP source credentials belong to the widget but remain outside its manifest.
`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
request to a different origin.
## Existing integrations
Sources can reuse any configured HTTP integration, including its saved credentials and login session. WebSocket-only integrations and the internal mock integration are excluded:
```json
{
"sources": { "default": { "type": "integration", "integrationKind": "sonarr" } },
"requests": { "queue": { "path": "/api/v3/queue" } }
}
```
Select the local instance during source setup. Homarr uses its current URL, API key, and trusted certificates;
paths append to the saved URL, including reverse-proxy subpaths. Changing the integration updates all sources bound to it.
Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement
of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID.
Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. Expired sessions are refreshed; queries can retry once, while actions require another explicit invocation.
## Named requests
Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `permission` have sensible defaults, so a load query can be as small as:
@@ -32,7 +32,6 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
@@ -97,15 +96,7 @@ const isNotFound = (caught: unknown) =>
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
type WidgetSource = HomarrCustomWidgetV2["sources"][string];
const sourceHost = (source: WidgetSource) => {
if (source.type === "integration") {
if (isHttpIntegrationKind(source.integrationKind))
return `${integrationDefs[source.integrationKind].name} integration`;
return `${source.integrationKind} integration`;
}
const { baseUrl } = source;
const sourceHost = (baseUrl: string) => {
try {
return new URL(baseUrl).host;
} catch {
@@ -113,30 +104,26 @@ const sourceHost = (source: WidgetSource) => {
}
};
const sourceAuthLabel = (source: WidgetSource) => {
if (source.type === "integration") return "Uses stored integration credentials";
const { auth } = source;
if (auth === "none") return "No credentials";
if (auth === "basic") return "Basic auth";
if (auth === "bearer") return "Bearer token";
if (auth.type === "apiKeyHeader") return `API key header · ${auth.name}`;
return `API key query · ${auth.name}`;
const sourceAuthLabel = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => {
if (typeof auth === "string")
return auth === "none" ? "No credentials" : auth === "basic" ? "Basic auth" : "Bearer token";
return auth.type === "apiKeyHeader" ? `API key header · ${auth.name}` : `API key query · ${auth.name}`;
};
const sourceNeedsSecret = (source: WidgetSource) => source.type === "integration" || source.auth !== "none";
const sourceNeedsSecret = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => auth !== "none";
const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
const sources = Object.entries(widget.sources);
const requests = Object.values(widget.requests);
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const queryCount = requests.filter((request) => request.kind === "query").length;
const actionCount = requests.filter((request) => request.kind === "action").length;
const credentialLabel =
protectedSources.length === 0
? "None required"
: protectedSources.length === 1
? sourceAuthLabel(protectedSources[0][1])
: `${protectedSources.length} authenticated sources`;
? sourceAuthLabel(protectedSources[0][1].auth)
: `${protectedSources.length} credentials required`;
return (
<section
@@ -165,9 +152,9 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
<dd className="mt-1.5 space-y-1.5">
{sources.slice(0, 2).map(([id, source]) => (
<span key={id} className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate font-mono text-xs font-medium">{sourceHost(source)}</span>
<span className="truncate font-mono text-xs font-medium">{sourceHost(source.baseUrl)}</span>
<Badge variant="secondary" className="shrink-0 font-normal">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</span>
))}
@@ -549,7 +536,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
@@ -792,11 +779,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{source.name || id}</span>
<Badge variant="secondary" className="shrink-0">
{source.networkScope ?? "integration"}
{source.networkScope}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source)}</p>
<p className="truncate text-xs text-muted-foreground">{sourceHost(source.baseUrl)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source.auth)}</p>
</div>
))}
</div>
@@ -16,9 +16,6 @@ import {
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import type { CustomWidgetSource } from "@homarr/custom-widgets/core";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import { useI18n } from "@homarr/translation/client";
interface RequestDetails {
@@ -27,7 +24,10 @@ interface RequestDetails {
kinds: Array<"apiKey" | "username" | "password">;
expiresAt: number;
status: "pending" | "completed";
source: CustomWidgetSource;
source: {
baseUrl: string;
networkScope: "public" | "private" | "loopback";
};
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
@@ -35,7 +35,6 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const tSecret = useI18n("customWidget.secret");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [integrationId, setIntegrationId] = useState<string>();
const [baseUrl, setBaseUrl] = useState("");
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
const [error, setError] = useState<string | null>(null);
@@ -48,9 +47,8 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const body = (await response.json()) as RequestDetails | { error: string };
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
setDetails(body);
setBaseUrl(body.source.baseUrl ?? "");
setNetworkScope(body.source.networkScope ?? "public");
setIntegrationId(body.source.integrationId);
setBaseUrl(body.source.baseUrl);
setNetworkScope(body.source.networkScope);
})
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
.finally(() => setLoading(false));
@@ -60,12 +58,10 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
setSaving(true);
setError(null);
try {
let configuration: unknown = { baseUrl, networkScope, secrets: values };
if (details?.source.type === "integration") configuration = { integrationId, secrets: {} };
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(configuration),
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
});
const body = (await response.json()) as { status?: string; error?: string };
if (!response.ok) throw new Error(body.error ?? t("saveError"));
@@ -106,31 +102,20 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
{t("source", { name: details.sourceName })}
</Text>
</Card>
{details.source.type === "integration" && (
<IntegrationSourceSelect
kind={details.source.integrationKind}
integrationId={integrationId}
onChange={setIntegrationId}
/>
)}
{details.source.type !== "integration" && (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
</>
)}
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
{details.kinds.map((kind) => {
const Input = kind === "username" ? TextInput : PasswordInput;
return (
@@ -147,10 +132,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
})}
<Button
loading={saving}
disabled={
(details.source.type === "integration" ? !integrationId : !URL.canParse(baseUrl)) ||
details.kinds.some((kind) => !values[kind])
}
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
onClick={() => void submit()}
>
{t("save")}
@@ -4,15 +4,9 @@ import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from
import { IconKey, IconTrash } from "@tabler/icons-react";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type {
CustomWidgetHttpSource,
CustomWidgetSource,
CustomWidgetSourceUrlIssue,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { useI18n } from "@homarr/translation/client";
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
@@ -58,6 +52,9 @@ export function CustomWidgetSourceField({
onRemove,
}: CustomWidgetSourceFieldProps) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<Fieldset legend={index === 0 ? t("primary") : source.name}>
@@ -76,41 +73,72 @@ export function CustomWidgetSourceField({
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
/>
</Group>
<Select
label={t("sourceType")}
data={[
{ value: "http", label: t("manualSource") },
{ value: "integration", label: t("existingIntegration") },
]}
value={source.type ?? "http"}
allowDeselect={false}
onChange={(type) => {
if (type === "integration") onUpdate(index, { type, integrationKind: "sonarr" });
else if (type === "http")
onUpdate(index, { type, baseUrl: "https://example.com", networkScope: "public", auth: "none" });
}}
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
{source.type === "integration" && (
<IntegrationSourceSelect
kind={source.integrationKind}
integrationId={source.integrationId}
onChange={(integrationId) => onUpdate(index, { integrationId })}
onKindChange={(integrationKind) => onUpdate(index, { integrationKind, integrationId: undefined })}
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{source.type !== "integration" && (
<CustomWidgetHttpSourceFields
source={source}
index={index}
form={form}
definitionId={definitionId}
clearSecretPending={clearSecretPending}
onUpdate={onUpdate}
onSetAuthentication={onSetAuthentication}
onSetSecret={onSetSecret}
onClearSecret={onClearSecret}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
@@ -126,88 +154,3 @@ export function CustomWidgetSourceField({
</Fieldset>
);
}
function CustomWidgetHttpSourceFields({
source,
index,
form,
definitionId,
clearSecretPending,
onUpdate,
onSetAuthentication,
onSetSecret,
onClearSecret,
}: Omit<CustomWidgetSourceFieldProps, "source" | "onRemove"> & { source: CustomWidgetHttpSource & { id: string } }) {
const t = useI18n("customWidget.workbench.sources");
const tSecret = useI18n("customWidget.secret");
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return (
<>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) => value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={authType}
onChange={(value) => onSetAuthentication(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
/>
)}
{(secretFields[authType] ?? []).map(({ kind }) => {
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Group key={kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={tSecret(kind)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretPending}
onClick={() => void onClearSecret(source.id, kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
</>
);
}
@@ -28,12 +28,6 @@ export function CustomWidgetSourcesEditor({
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
if (changes.type) {
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== sources[index]?.id),
);
}
const previousId = sources[index]?.id;
const nextId = changes.id ?? previousId;
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
@@ -48,7 +42,7 @@ export function CustomWidgetSourcesEditor({
Object.fromEntries(
sourceEntries.map(([id, source], i) => [
i === index ? nextId : id,
i === index ? { ...(changes.type ? { name: source.name } : source), ...changes, id: undefined } : source,
i === index ? { ...source, ...changes, id: undefined } : source,
]),
),
null,
@@ -1,6 +1,5 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
@@ -23,7 +22,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
@@ -191,7 +190,7 @@ export function WorkshopDetail({ id }: { id: string }) {
</Text>
</Box>
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import {
claimCustomWidgetConfigurationRequest,
assertCustomWidgetIntegrationBindings,
completeCustomWidgetConfigurationRequest,
configureCustomWidgetSourceFromRequest,
configurePreviewSessionSource,
@@ -13,7 +12,6 @@ import {
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { db } from "@homarr/db";
import { auth } from "@homarr/auth/next";
import { adminRoute } from "../../admin";
import { readConfigurationRequestBody } from "../body";
@@ -54,40 +52,17 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
let candidate: unknown;
if (pending.source.type === "integration") {
if (
!body.integrationId ||
body.baseUrl !== undefined ||
body.networkScope !== undefined ||
Object.keys(body.secrets).length > 0
) {
return NextResponse.json({ error: "Select an existing integration." }, { status: 400 });
}
candidate = { ...pending.source, integrationId: body.integrationId };
} else {
if (body.integrationId)
return NextResponse.json({ error: "HTTP sources cannot bind an integration." }, { status: 400 });
candidate = { ...pending.source, baseUrl: body.baseUrl, networkScope: body.networkScope };
}
const sourceResult = customWidgetSourceSchema.safeParse(candidate);
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
{ status: 400 },
);
}
try {
await assertCustomWidgetIntegrationBindings(
{ db, session: await auth() },
{ [pending.sourceId]: sourceResult.data },
);
} catch {
return NextResponse.json(
{ error: "The selected integration is unavailable or has a different type." },
{ status: 400 },
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
@@ -120,7 +95,6 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
const result = await configureCustomWidgetSourceFromRequest(db, {
definitionId: claimed.target.id,
sourceId: claimed.sourceId,
integrationId: sourceResult.data.integrationId,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
secrets,
@@ -1,17 +1,11 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z
.strictObject({
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: z.record(z.string(), z.string()),
})
.refine(
(value) => Boolean(value.integrationId) || (value.baseUrl !== undefined && value.networkScope !== undefined),
"Provide an integration or HTTP connection",
);
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
@@ -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,10 +1,9 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import type { ModalProps } from "@mantine/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useI18n } from "@homarr/translation/client";
import { useCustomWidgetImport } from "./use-custom-widget-import";
@@ -68,7 +67,7 @@ export function CustomWidgetImportDialog({
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
}}
>
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
@@ -1,6 +1,5 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
@@ -9,7 +8,11 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import { createCustomWidgetSourceSetupValues, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupMessages, CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -65,14 +68,6 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
if (setup.integrationKind) {
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
integrationId: value.integrationId,
});
continue;
}
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
@@ -114,7 +109,7 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
</Text>
) : (
<Stack gap="md">
<SourceSetupPanel
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
@@ -1,81 +0,0 @@
"use client";
import { Alert, Select, Stack, Text } from "@mantine/core";
import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench";
import { clientApi } from "@homarr/api/client";
import type { CustomWidgetIntegrationSource } from "@homarr/custom-widgets/core";
import { integrationDefs, integrationKinds, isHttpIntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
const httpIntegrationKinds = integrationKinds.filter(isHttpIntegrationKind);
interface IntegrationSourceSelectProps {
kind: CustomWidgetIntegrationSource["integrationKind"];
integrationId?: string;
onChange(integrationId: string | undefined): void;
onKindChange?(kind: CustomWidgetIntegrationSource["integrationKind"]): void;
}
export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindChange }: IntegrationSourceSelectProps) {
const t = useI18n("customWidget.workbench.sources");
const query = clientApi.integration.all.useQuery();
const integrations = (query.data ?? []).filter(
(integration) => integration.kind === kind && integration.permissions.hasFullAccess,
);
const unavailable = Boolean(integrationId && query.isSuccess && !integrations.some(({ id }) => id === integrationId));
return (
<Stack gap="sm">
{onKindChange && (
<Select
label={t("integrationType")}
searchable
data={httpIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
value={kind}
allowDeselect={false}
onChange={(value) => {
const selected = httpIntegrationKinds.find((candidate) => candidate === value);
if (selected) onKindChange(selected);
}}
/>
)}
<Select
label={t("selectIntegration")}
description={isHttpIntegrationKind(kind) ? integrationDefs[kind].name : kind}
data={integrations.map(({ id, name }) => ({ value: id, label: name }))}
value={integrationId ?? null}
searchable
clearable
disabled={query.isPending}
nothingFoundMessage={t("noIntegrations")}
error={unavailable && t("integrationUnavailable")}
onChange={(value) => onChange(value ?? undefined)}
/>
{query.error && <Alert color="red">{query.error.message}</Alert>}
<Text size="xs" c="dimmed">
{t("integrationDescription")}
</Text>
</Stack>
);
}
export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) {
return (
<CustomWidgetSourceSetupPanel
{...props}
renderIntegrationSource={(setup, value) => {
const kind = httpIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
if (!kind) return null;
return (
<IntegrationSourceSelect
kind={kind}
integrationId={value.integrationId}
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
/>
);
}}
/>
);
}
@@ -60,7 +60,7 @@ export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCusto
Object.fromEntries(
Object.entries(values).map(([sourceId, value]) => [
sourceId,
{ baseUrl: value.baseUrl, networkScope: value.networkScope, integrationId: value.integrationId },
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
]),
),
),
@@ -1,5 +1,5 @@
interface PublishableWorkshopDefinition {
sources: Record<string, { name?: string; networkScope?: string }>;
sources: Record<string, { name?: string; networkScope: string }>;
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
@@ -29,5 +29,5 @@ export async function publishWorkshopDefinition({
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
source.networkScope === undefined || source.networkScope === "public" ? [] : [source.name ?? sourceId],
source.networkScope === "public" ? [] : [source.name ?? sourceId],
);
@@ -5,5 +5,3 @@ export {
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
export { assertCustomWidgetIntegrationBindings } from "./router/custom-widget/source-resolver";
@@ -1,4 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -45,7 +44,6 @@ export const creationProcedures = {
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
@@ -117,7 +115,6 @@ export const creationProcedures = {
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
);
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition from tested preview", {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -62,7 +60,6 @@ export const customWidgetRouter = createTRPCRouter({
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
});
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
@@ -109,7 +106,7 @@ export const customWidgetRouter = createTRPCRouter({
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(
eq(schema.customWidgetSecrets.definitionId, id),
eq(schema.customWidgetSecrets.sourceId, sourceId),
@@ -154,7 +151,7 @@ export const customWidgetRouter = createTRPCRouter({
}
for (const [sourceId, source] of Object.entries(definition.sources)) {
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
transaction
.delete(customWidgetSecrets)
@@ -1,4 +1,3 @@
import { resolveCustomWidgetSource } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -154,19 +153,31 @@ export const managementQueryProcedures = {
});
try {
const values = resolveOptionRequestValues(request, input.params);
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
stored.secrets
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const response = await executeCustomWidgetRequest({
...connection,
targetUrl: renderRequestTarget(connection.baseUrl, request, values),
baseUrl: source.baseUrl,
targetUrl: renderRequestTarget(source.baseUrl, request, values),
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth:
request.auth === "none" || authType === "none"
? undefined
: {
type: authType,
secrets: stored.secrets
.filter((secret) => secret.sourceId === request.source)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
},
networkScope: source.networkScope,
kind: "query",
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}:${connection.cacheVersion}`,
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
cacheTtlSeconds: request.cacheSeconds ?? 30,
});
return {
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { InferSelectModel } from "drizzle-orm";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -51,12 +50,12 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
sources: Object.entries(widget.sources).map(([id, source]) => ({
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
id,
name: source.name,
origin: source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
networkScope: source.networkScope,
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
name,
origin: new URL(baseUrl).origin,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCount: Object.keys(widget.requests).length,
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
@@ -111,11 +110,11 @@ export function mapAvailableCustomWidget(definition: StoredDefinition) {
options: widget.options,
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
template: widget.template,
sources: Object.entries(widget.sources).map(([id, source]) => ({
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
id,
name: source.name,
networkScope: source.networkScope,
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
name,
networkScope,
authType: typeof auth === "string" ? auth : auth.type,
})),
requestCapabilities: Object.entries(widget.requests).map(
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
@@ -29,10 +29,10 @@ export const previewActionProcedures = {
if (definition?.kind !== "action")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const request = { id: input.requestId, ...definition };
const resolved = await getPreviewRequestSource(ctx, session, request);
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
@@ -66,12 +66,13 @@ export const previewActionProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
...resolved,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "action",
});
if (response.ok && request.invalidates?.length) {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -94,7 +92,6 @@ const previewCreateProcedure = permissionRequiredProcedure
const definition = parseCustomWidgetAuthoringInput(() =>
normalizeCustomWidgetAuthoringDefinition(input.definition),
);
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
const optionIssues = validateCustomWidgetOptions(definition.options, options);
if (optionIssues.length > 0) {
@@ -118,7 +115,7 @@ const previewCreateProcedure = permissionRequiredProcedure
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
const authType = getCustomWidgetSourceAuthType(submittedSource);
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
const missingReplacement = requiredSecretKinds(authType).some(
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
);
@@ -145,7 +142,7 @@ const previewCreateProcedure = permissionRequiredProcedure
const invalid = secrets.find((secret) => {
const source = definition.sources[secret.sourceId];
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
? ["username", "password"]
@@ -1,4 +1,3 @@
import { resolveCustomWidgetSource } from "./source-resolver";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { z } from "zod/v4";
@@ -34,15 +33,25 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
}
};
export async function getPreviewRequestSource(
ctx: Parameters<typeof resolveCustomWidgetSource>[0],
session: CustomWidgetPreviewSession,
request: CustomJsxRequest,
) {
const source = session.sources[request.source];
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
const source = session.sources[sourceId];
if (!source) return null;
return resolveCustomWidgetSource(ctx, source, request, () => getPreviewSessionSecrets(session, request.source));
}
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
const auth =
authType === "none"
? undefined
: {
type: authType,
secrets: getPreviewSessionSecrets(session, sourceId),
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
return { source, auth };
};
export function resolvePreviewRequestParams(
request: CustomJsxRequest,
@@ -38,10 +38,10 @@ export const previewQueryProcedures = {
if (definition?.kind !== "query")
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const request = { id: input.requestId, ...definition };
const resolved = await getPreviewRequestSource(ctx, session, request);
const resolved = getPreviewRequestSource(session, request.source);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
const params = resolvePreviewRequestParams(request, session.options, input.params);
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
const body = renderRequestBody(request, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
@@ -52,14 +52,15 @@ export const previewQueryProcedures = {
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
...resolved,
baseUrl: resolved.source.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.headers,
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}:${resolved.cacheVersion}`,
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheSeconds,
});
await recordPreviewJournal(session, {
@@ -15,8 +15,7 @@ type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
type ConfigureCustomWidgetSourceInput = {
definitionId: string;
sourceId: string;
baseUrl?: string;
integrationId?: string;
baseUrl: string;
networkScope?: CustomWidgetSource["networkScope"];
secrets: CustomWidgetCreateInput["secrets"];
expectedSource?: CustomWidgetSource;
@@ -221,17 +220,11 @@ function prepareSourceUpdate(
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
throw new SourceConfigurationError("binding-changed");
}
let configuration: unknown;
if (previousSource.type === "integration") {
configuration = { ...previousSource, integrationId: input.integrationId };
} else {
configuration = {
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
};
}
const source = customWidgetSourceSchema.parse(configuration);
const source = customWidgetSourceSchema.parse({
...previousSource,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? previousSource.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
return {
source,
@@ -16,7 +16,6 @@ export function requiredSecretKinds(authType: string) {
* origin, while the network scope and auth destination are part of the binding.
*/
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
if (left.type === "integration" || right.type === "integration") return false;
return (
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
left.networkScope === right.networkScope &&
@@ -25,12 +24,12 @@ export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidg
}
export function assertSecretSources(
sources: Record<string, { auth?: string | { type: string }; type?: string }>,
sources: Record<string, { auth: string | { type: string } }>,
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
) {
const invalid = secrets.find((secret) => {
const source = sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth?.type;
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
});
if (invalid) {
@@ -1,5 +1,3 @@
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -60,19 +58,12 @@ export const secretProcedures = {
sourceConfigure: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Configure one custom widget source. For an integration source, provide integrationId from integration_all (matching integrationKind); omit URL and secrets. Requires administrator permission.",
},
})
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
.input(
z.object({
definitionId: z.string(),
sourceId: z.string(),
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
secrets: customWidgetSecretsInputSchema.default([]),
}),
@@ -85,34 +76,22 @@ export const secretProcedures = {
const definition = parseStoredCustomWidgetDefinition(stored);
const current = definition.sources[input.sourceId];
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
let configuration: unknown;
if (current.type === "integration") {
if (
!input.integrationId ||
input.baseUrl !== undefined ||
input.networkScope !== undefined ||
input.secrets.length > 0
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Integration sources require only an integrationId" });
}
configuration = { ...current, integrationId: input.integrationId };
} else {
if (input.integrationId)
throw new TRPCError({ code: "BAD_REQUEST", message: "HTTP sources cannot bind an integration" });
configuration = {
...current,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? current.networkScope,
};
}
const source = customWidgetSourceSchema.parse(configuration);
await assertCustomWidgetIntegrationBindings(ctx, { [input.sourceId]: source });
const source = customWidgetSourceSchema.parse({
...current,
baseUrl: input.baseUrl,
networkScope: input.networkScope ?? current.networkScope,
});
assertSecretSources({ [input.sourceId]: source }, input.secrets);
const configuredSource = await configureCustomWidgetSource(ctx.db, { ...input, expectedSource: current });
const configuredSource = await configureCustomWidgetSource(ctx.db, {
definitionId: input.definitionId,
sourceId: input.sourceId,
baseUrl: source.baseUrl,
networkScope: input.networkScope,
secrets: input.secrets,
});
return {
definitionId: input.definitionId,
sourceId: input.sourceId,
integrationId: configuredSource.integrationId,
baseUrl: configuredSource.baseUrl,
networkScope: configuredSource.networkScope,
configuredSecrets: input.secrets.map(({ kind }) => kind),
@@ -131,7 +110,7 @@ export const secretProcedures = {
}
let widgetName: string;
let source: { id: string; name: string; auth: CustomWidgetSource["auth"]; value: CustomWidgetSource } | undefined;
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
if (input.definitionId) {
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -165,7 +144,7 @@ export const secretProcedures = {
target = { type: "preview", id: preview.id };
}
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
const request = await createCustomWidgetConfigurationRequest({
userId: ctx.session.user.id,
target,
@@ -1,85 +0,0 @@
import { TRPCError } from "@trpc/server";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { isHttpIntegrationKind } from "@homarr/definitions";
import type { IntegrationHttpContext } from "../integration/integration-http";
import {
getIntegrationForHttpRequest,
getIntegrationHttpCacheVersion,
getIntegrationHttpConnection,
} from "../integration/integration-http";
export async function assertCustomWidgetIntegrationBindings(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
for (const source of Object.values(sources)) {
if (source.type !== "integration" || !source.integrationId) continue;
await resolveIntegration(ctx, source);
}
}
async function resolveIntegration(
ctx: IntegrationHttpContext,
source: Extract<CustomWidgetSource, { type: "integration" }>,
) {
if (!source.integrationId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Select an existing integration for this widget source",
});
}
const integration = await getIntegrationForHttpRequest(ctx, source.integrationId);
if (!isHttpIntegrationKind(integration.kind) || integration.kind !== source.integrationKind) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "The selected integration is unavailable or has a different type",
});
}
return integration;
}
export async function resolveCustomWidgetSource(
ctx: IntegrationHttpContext,
source: CustomWidgetSource,
request: Pick<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<
CustomWidgetHttpRequest,
"baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets" | "resolveConnectionAsync"
> & {
cacheVersion: string;
}
> {
if (source.type !== "integration") {
const authType = getCustomWidgetSourceAuthType(source);
let auth: CustomWidgetHttpRequest["auth"];
if (request.auth !== "none" && authType !== "none") {
auth = { type: authType, secrets: getSecrets() };
if (typeof source.auth === "object") auth.headerName = source.auth.name;
}
return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" };
}
const integration = await resolveIntegration(ctx, source);
return getIntegrationHttpConnection(integration, request.auth !== "none");
}
export async function getCustomWidgetIntegrationCacheVersions(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
) {
return Promise.all(
Object.entries(sources).flatMap(([sourceId, source]) => {
if (source.type !== "integration") return [];
return [
resolveIntegration(ctx, source)
.then((integration) => `${sourceId}:${getIntegrationHttpCacheVersion(integration)}`)
.catch(() => `${sourceId}:unavailable`),
];
}),
);
}
@@ -1,5 +1,3 @@
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
@@ -26,7 +24,7 @@ export const transferProcedures = {
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return exportCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition));
return parseStoredCustomWidgetDefinition(definition);
}),
exportLegacy: permissionRequiredProcedure
@@ -59,7 +57,6 @@ export const transferProcedures = {
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
@@ -83,7 +80,6 @@ export const transferProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
@@ -161,7 +157,7 @@ function canPreserveLegacySecrets(
widget: z.infer<typeof customWidgetImportSchema>,
) {
const source = widget.sources.default;
if (!source || source.type === "integration" || getAuthType(source.auth) !== legacy.authType) return false;
if (!source || getAuthType(source.auth) !== legacy.authType) return false;
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
return headerName === (legacy.headerName ?? undefined);
@@ -1,5 +1,3 @@
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -58,7 +56,7 @@ async function getWorkshopWidget(submissionId: string) {
if (!validation.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
}
return { submission, widget: exportCustomWidgetDefinition(validation.data) };
return { submission, widget: validation.data };
} catch (error) {
if (error instanceof TRPCError) throw error;
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
@@ -74,8 +72,7 @@ const workshopSearchInputSchema = z.object({
const sourceOverridesSchema = z.record(
customWidgetIdentifierSchema,
z.strictObject({
baseUrl: z.string().optional(),
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]).optional(),
}),
);
@@ -168,14 +165,12 @@ export const workshopProcedures = {
sourceId,
{
baseUrl: source.baseUrl,
integrationId: source.integrationId,
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
},
]),
),
),
});
await assertCustomWidgetIntegrationBindings(ctx, configured.sources);
assertSecretSources(configured.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, input.secrets);
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
@@ -1,126 +0,0 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
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 } from "@homarr/custom-widgets/server";
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { getIntegrationHttpAuthenticationAsync } from "@homarr/integrations/factory";
import type { IntegrationHttpAuthentication } from "@homarr/integrations/factory";
export interface IntegrationHttpContext {
db: Database;
session: Session | null;
}
export async function getIntegrationForHttpRequest(ctx: IntegrationHttpContext, integrationId: string) {
const userId = ctx.session?.user.id ?? "";
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId) });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, userId) },
groupPermissions: {
where: inArray(integrationGroupPermissions.groupId, groups.map(({ groupId }) => groupId).concat("")),
},
},
});
// An arbitrary read can expose service credentials or perform a mutation. Native
// integration use/interact permissions authorize only Homarr's bounded operations.
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Arbitrary integration requests require full integration access",
});
}
return integration;
}
type HttpIntegration = Awaited<ReturnType<typeof getIntegrationForHttpRequest>>;
export function getIntegrationHttpCacheVersion(integration: HttpIntegration) {
return createHash("sha256")
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
.digest("hex")
.slice(0, 16);
}
export async function getIntegrationHttpConnection(integration: HttpIntegration, authenticate = true) {
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
let baseUrl = integration.url;
if (integration.kind === "ical") baseUrl = secrets.find(({ kind }) => kind === "url")?.value ?? "";
return {
baseUrl,
networkScope: "loopback" as const,
pathPrefix: new URL(baseUrl).pathname,
redactSecrets: secrets,
cacheVersion: getIntegrationHttpCacheVersion(integration),
resolveConnectionAsync: async () => {
try {
let authentication: IntegrationHttpAuthentication = {};
if (authenticate) {
authentication = await getIntegrationHttpAuthenticationAsync({
...integration,
externalUrl: null,
decryptedSecrets: secrets,
});
}
const auth = toWidgetAuth(authentication, secrets);
const url = new URL(authentication.baseUrl ?? baseUrl);
let tls: CustomWidgetHttpRequest["tls"];
if (url.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
return { baseUrl: url.href, auth, tls, pathPrefix: url.pathname, redactSecrets: auth.secrets };
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration connection could not be resolved",
});
}
},
};
} catch {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" });
}
}
function toWidgetAuth(
authentication: IntegrationHttpAuthentication,
secrets: CustomWidgetAuthConfig["secrets"],
): CustomWidgetAuthConfig {
const authSecrets = [...secrets, ...(authentication.redactValues ?? []).map((value) => ({ kind: "session", value }))];
const refresh = authentication.refreshAsync;
return {
...authentication,
type: "integration",
secrets: authSecrets,
refreshAsync:
refresh &&
(async () => {
try {
return toWidgetAuth(await refresh(), secrets);
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration session could not be refreshed",
});
}
}),
};
}
@@ -42,8 +42,7 @@ if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
const jellyfinDefaultSource = jellyfin.sources.default;
if (!jellyfinDefaultSource || jellyfinDefaultSource.type === "integration")
throw new Error("Jellyfin default source is missing");
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
@@ -182,7 +181,7 @@ describe("custom widget definition persistence", () => {
}
const defaultSource = previewSession.sources.default;
if (!defaultSource || defaultSource.type === "integration") throw new Error("Pokédex preview source is missing");
if (!defaultSource) throw new Error("Pokédex preview source is missing");
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
@@ -676,7 +675,7 @@ describe("custom widget definition persistence", () => {
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const defaultSource = definition.sources.default;
if (!defaultSource || defaultSource.type === "integration") throw new Error("Jellyfin default source is missing");
if (!defaultSource) throw new Error("Jellyfin default source is missing");
await db
.update(customWidgetDefinitions)
.set(
@@ -721,7 +720,7 @@ describe("custom widget definition persistence", () => {
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
const definition = customWidgetDefinitionSchema.parse(jellyfin);
const expectedSource = definition.sources.default;
if (!expectedSource || expectedSource.type === "integration" || typeof expectedSource.auth === "string") {
if (!expectedSource || typeof expectedSource.auth === "string") {
throw new Error("Jellyfin default source must use header authentication");
}
await db
+27 -15
View File
@@ -1,4 +1,3 @@
import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
@@ -14,7 +13,7 @@ import {
normalizeCustomWidgetOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
@@ -122,6 +121,25 @@ const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
};
type IdentifiedRequest = CustomJsxRequest & { id: string };
type IdentifiedSource = CustomWidgetSource & { id: string };
const getAuth = (resolved: ResolvedDefinition, source: IdentifiedSource, mode: "inherit" | "none") => {
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
if (mode === "none" || authType === "none") return undefined;
const secrets = resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) }));
return {
type: authType,
secrets,
headerName:
typeof source.auth === "object" && source.auth.type === "apiKeyHeader"
? source.auth.name
: typeof source.auth === "object" && source.auth.type === "apiKeyQuery"
? source.auth.name
: undefined,
};
};
const withRequestLimit = async <T>(
ctx: RouterContext,
@@ -154,26 +172,21 @@ const executeRequest = async (
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
const source = findSource(resolved, request.source);
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
resolved.stored.secrets
.filter((secret) => secret.sourceId === source.id)
.map((secret) => ({ kind: secret.kind, value: decryptSecret(secret.encryptedValue) })),
);
const targetUrl = renderRequestTarget(connection.baseUrl, request, values);
const response = await withRequestLimit(ctx, resolved, request, () =>
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
return withRequestLimit(ctx, resolved, request, () =>
executeCustomWidgetRequest({
...connection,
baseUrl: source.baseUrl,
targetUrl,
method: request.method,
body: renderRequestBody(request, values),
staticHeaders: request.headers,
auth: getAuth(resolved, source, request.auth),
networkScope: source.networkScope,
kind: request.kind,
cacheKey:
request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined,
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
cacheTtlSeconds: request.cacheSeconds,
}),
);
return response;
};
export const customApiRouter = createTRPCRouter({
@@ -186,7 +199,6 @@ export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources);
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
@@ -227,7 +239,7 @@ export const customApiRouter = createTRPCRouter({
return {
type: "customJsx" as const,
template: resolved.definition.template,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.join(":")}`,
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "../src/core/request-schema";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -280,7 +279,7 @@ export function getDeterministicEvaluationIssues(
message: `Use the verified source URL ${expectations.sourceBaseUrl}.`,
});
}
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
if (authType !== expectations.sourceAuth) {
issues.push({
path: ["sources", "default", "auth"],
@@ -468,7 +467,7 @@ const getJudgeRuntimeContext = (widget: HomarrCustomWidgetV2) => {
const sections = [getCustomWidgetSkillReference("runtime").content];
const hasMutation = Object.values(widget.requests).some((request) => request.kind === "action");
const hasProtectedSource = Object.values(widget.sources).some((source) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return authType !== "none";
});
if (hasMutation || hasProtectedSource) sections.push(getCustomWidgetSkillReference("security").content);
@@ -72,8 +72,6 @@ ${leanShape}
Sources are keyed by name and must include "default". Auth is "none", "bearer", "basic", {"type":"apiKeyHeader","name":"X-Api-Key"}, or {"type":"apiKeyQuery","name":"api_key"}. Use the stable public API URL for public services and a clear suggested URL for self-hosted services; Homarr asks the installer for their own server URL. Never put credentials in the manifest.
Supported existing integrations use {"type":"integration","integrationKind":"sonarr"} as a source, without baseUrl, networkScope, or auth. The installer selects their local integration. An MCP author can use integration_all to obtain a matching integrationId for preview/create. Integration URLs and credentials resolve server-side; exports remove integrationId. All integration calls require full integration access. Integration methods other than GET must be manual actions.
Requests are keyed by ID. Defaults are source "default", kind "query", method "GET", query trigger "load", inherited auth, and permission "view" for queries or "modify" for actions. Actions are always manual. DELETE is valid only for actions, requires full permission, and receives confirmation automatically. Use {option:name} or {"$option":"name"} for saved options. Use {param:name} or {"$param":"name"} only for invocation-time params supplied by SubFetch, ActionButton, or ToggleSwitch. Load queries cannot use params. Values and primitive types are inferred from references; do not declare parameters or option bindings. Paths and query values must be primitive; JSON bodies may bind structured options.
Options are keyed by name. Every option has label, control, and default. Controls: text, textarea, number, switch, select, multiSelect, slider, date, time, color, icon, url, duration, timeZone, json. Select choices use \`"choices": [{"label":"...","value":"..."}]\`. Dynamic choices must use \`"choicesFrom": {"request":"requestId","itemsPath":"optional.path","valuePath":"id","labelPath":"name"}\`, never an object under \`choices\`. Options are configured outside the widget and read through \`options.name\`; a bound control writes only to \`inputs.name\` and never changes an option.
@@ -13,7 +13,7 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.10.0";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.9.0";
export const CUSTOM_WIDGET_SKILL_REFERENCE_NAMES = ["schema", "runtime", "security"] as const;
export type CustomWidgetSkillReferenceName = (typeof CUSTOM_WIDGET_SKILL_REFERENCE_NAMES)[number];
@@ -58,7 +58,7 @@ interface HomarrCustomWidgetV2 {
}
\`\`\`
\`sources.default\` is required. HTTP source properties: \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
The object key \`default\` is the required source ID; \`default\` is not a property on a source. Source properties are \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
\`\`\`json
{
@@ -85,17 +85,14 @@ interface HomarrCustomWidgetV2 {
}
\`\`\`
Integration sources use \`{"type":"integration","integrationKind":"sonarr"}\`. Bind \`integrationId\` from \`integration_all\`; exports omit it. Calls require full integration access. Omit URL/auth fields. Paths append to the integration URL; non-GET requests must be actions.
Auth is \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. A request defaults to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Set \`trigger: "manual"\` for a parameterized query. An action defaults to manual and modify permission. DELETE uses full permission and confirmation. Do not use \`load: false\`.
Auth: \`none\`, \`bearer\`, \`basic\`, \`{ "type": "apiKeyHeader", "name": "X-Api-Key" }\`, or \`{ "type": "apiKeyQuery", "name": "api_key" }\`. Requests default to source \`default\`, kind \`query\`, method \`GET\`, trigger \`load\`, inherited auth, and view permission. Parameterized queries need \`trigger: "manual"\`. Actions default to manual/modify; DELETE requires full permission and confirmation. Do not use \`load: false\`.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
Paths use \`{option:name}\` and \`{param:name}\`; query/body references use \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Constants stay primitive (\`take: 10\`); \`$param\` is only for manual helpers, never load queries. Names and types are inferred.
Paths bind \`{option:name}\` and \`{param:name}\`; query/body references bind \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Use primitive constants (\`take: 10\`). \`$param\` is only for manual requests.
Options require \`label\`, \`control\`, and \`default\`. Optional fields: \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, \`group\`.
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
`,
"references/runtime.md": `# Runtime
Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status is \`{ loading, ok, status, statusText, error }\`. Render load queries directly from \`data\` and \`status\` with \`RefreshButton\`; never wrap them in \`SubFetch\`.
@@ -104,17 +104,6 @@ type Definition = z.infer<typeof customWidgetDefinitionSchema>;
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
for (const [requestId, request] of Object.entries(definition.requests)) {
if (
definition.sources[request.source]?.type === "integration" &&
request.method !== "GET" &&
request.kind !== "action"
) {
ctx.addIssue({
code: "custom",
path: ["requests", requestId, "kind"],
message: "Integration requests with a method other than GET must be manual actions",
});
}
if (!definition.sources[request.source]) {
ctx.addIssue({
code: "custom",
+4 -23
View File
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customWidgetImportSchema } from "./custom-jsx-schema";
@@ -105,7 +104,7 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
const parsed = customWidgetImportSchema.safeParse(widget);
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
return { success: true, widget: exportCustomWidgetDefinition(parsed.data), warnings: [] };
return { success: true, widget: parsed.data, warnings: [] };
}
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
@@ -158,35 +157,17 @@ export function getImportReview(value: unknown): ImportReview | null {
const widget = parsed.data;
return {
name: widget.name,
origins: Object.values(widget.sources).map((source) =>
source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
),
origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin),
authTypes: [
...new Set(
Object.values(widget.sources).map((source) =>
source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
typeof source.auth === "string" ? source.auth : source.auth.type,
),
),
],
networkScopes: [
...new Set(Object.values(widget.sources).flatMap((source) => (source.networkScope ? [source.networkScope] : []))),
],
networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))],
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
};
}
/** Deployment bindings never travel with an exported or published widget. */
export function exportCustomWidgetDefinition(widget: HomarrCustomWidgetV2): HomarrCustomWidgetV2 {
return {
...widget,
sources: Object.fromEntries(
Object.entries(widget.sources).map(([id, source]) => {
if (source.type !== "integration") return [id, source];
const { integrationId: _localBinding, ...portable } = source;
return [id, portable];
}),
),
};
}
@@ -41,10 +41,7 @@ const sourceUrlIssueMessages: Record<CustomWidgetSourceUrlIssue, string> = {
queryOrFragment: "API source URLs cannot contain a query string or fragment",
};
export const customWidgetHttpSourceSchema = z.strictObject({
type: z.literal("http").optional(),
integrationKind: z.never().optional(),
integrationId: z.never().optional(),
export const customWidgetSourceSchema = z.strictObject({
name: z.string().trim().min(1).max(128).optional(),
baseUrl: z
.string()
@@ -57,25 +54,7 @@ export const customWidgetHttpSourceSchema = z.strictObject({
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
export const customWidgetIntegrationSourceSchema = z.strictObject({
type: z.literal("integration"),
name: z.string().trim().min(1).max(128).optional(),
integrationKind: customWidgetIdentifierSchema,
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.never().optional(),
networkScope: z.never().optional(),
auth: z.never().optional(),
});
export const customWidgetSourceSchema = z.union([customWidgetHttpSourceSchema, customWidgetIntegrationSourceSchema]);
export type CustomWidgetSource = z.infer<typeof customWidgetSourceSchema>;
export type CustomWidgetHttpSource = z.infer<typeof customWidgetHttpSourceSchema>;
export type CustomWidgetIntegrationSource = z.infer<typeof customWidgetIntegrationSourceSchema>;
export function getCustomWidgetSourceAuthType(source: { auth?: CustomWidgetHttpSource["auth"] } | undefined) {
if (!source?.auth) return "none" as const;
if (typeof source.auth === "string") return source.auth;
return source.auth.type;
}
export const customWidgetSourcesSchema = z
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
@@ -1,4 +1,3 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
@@ -22,7 +21,7 @@ export function getCustomWidgetSecretRequirements(
sources: Record<string, CustomWidgetSource>,
): CustomWidgetSecretRequirement[] {
return Object.entries(sources).flatMap(([sourceId, source]) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({
sourceId,
sourceName: source.name ?? sourceId,
@@ -1,5 +1,4 @@
import { getCustomWidgetSourceAuthType } from "./request-schema";
import type { CustomJsxNetworkScope, CustomWidgetSource } from "./request-schema";
import type { CustomWidgetSource } from "./request-schema";
import { getCustomWidgetSourceUrlIssue } from "./request-schema";
import type { CustomWidgetSecretKind } from "./schema-types";
import { getCustomWidgetSecretRequirements } from "./secret-requirements";
@@ -16,9 +15,7 @@ export interface CustomWidgetSourceSetup {
sourceName: string;
suggestedBaseUrl: string;
baseUrl: string;
networkScope: CustomJsxNetworkScope;
integrationKind?: string;
integrationId?: string;
networkScope: CustomWidgetSource["networkScope"];
authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
credentialFields: Array<{
kind: CustomWidgetSecretKind;
@@ -43,18 +40,10 @@ export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) {
}
export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) {
if (source.type === "integration") return !source.integrationId;
return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl);
}
export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) {
if (left.type === "integration" || right.type === "integration") {
return (
left.type === right.type &&
left.integrationKind === right.integrationKind &&
left.integrationId === right.integrationId
);
}
if (typeof left.auth === "string" || typeof right.auth === "string") return left.auth === right.auth;
return left.auth.type === right.auth.type && left.auth.name === right.auth.name;
}
@@ -65,15 +54,13 @@ export function getCustomWidgetSourceSetups(
): CustomWidgetSourceSetup[] {
const requirements = getCustomWidgetSecretRequirements(sources);
return Object.entries(sources).map(([sourceId, source]) => {
const authType = getCustomWidgetSourceAuthType(source);
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
return {
sourceId,
sourceName: source.name ?? sourceId,
suggestedBaseUrl: source.baseUrl ?? "",
baseUrl: source.baseUrl ?? "",
networkScope: source.networkScope ?? "private",
integrationKind: source.integrationKind,
integrationId: source.integrationId,
suggestedBaseUrl: source.baseUrl,
baseUrl: source.baseUrl,
networkScope: source.networkScope,
authType,
credentialFields: requirements
.filter((requirement) => requirement.sourceId === sourceId)
@@ -94,23 +81,12 @@ export function getCustomWidgetSourceSetups(
export function applyCustomWidgetSourceSetup(
sources: Record<string, CustomWidgetSource>,
setup: Record<string, { baseUrl?: string; networkScope?: CustomJsxNetworkScope; integrationId?: string }>,
setup: Record<string, Pick<CustomWidgetSource, "baseUrl" | "networkScope">>,
) {
return Object.fromEntries(
Object.entries(sources).map(([sourceId, source]) => {
const configured = setup[sourceId];
if (!configured) return [sourceId, source];
if (source.type === "integration") {
return [sourceId, { ...source, integrationId: configured.integrationId }];
}
return [
sourceId,
{
...source,
baseUrl: configured.baseUrl ?? source.baseUrl,
networkScope: configured.networkScope ?? source.networkScope,
},
];
return [sourceId, configured ? { ...source, ...configured } : source];
}),
);
}
@@ -1,6 +1,3 @@
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
@@ -46,54 +43,3 @@ export function applyAuth(
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
export async function performAuthenticatedRequest(
input: CustomWidgetHttpRequest,
signal: AbortSignal,
perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix);
if (input.resolveConnectionAsync) {
const connection = await withinDeadline(input.resolveConnectionAsync, signal);
const base = new URL(connection.baseUrl);
target.protocol = base.protocol;
target.host = base.host;
input = { ...input, ...connection, targetUrl: target };
}
let response = await perform(input, signal);
if (!isExpired(input, response)) return response;
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
if (!input.auth?.refreshAsync) return response;
const auth = await withinDeadline(input.auth.refreshAsync, signal);
// An action may have reached the service. Refresh for its next explicit invocation.
if (input.kind === "action") return response;
input = { ...input, auth };
response = await perform(input, signal);
if (isExpired(input, response)) {
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
}
return response;
}
function isExpired(input: CustomWidgetHttpRequest, response: CustomWidgetHttpResponse) {
return input.auth && (response.status === 401 || input.auth.isExpired?.(response));
}
async function withinDeadline<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let abort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([callback(), aborted]);
} finally {
if (abort) signal.removeEventListener("abort", abort);
}
}
@@ -39,7 +39,3 @@ export {
resolveCustomWidgetRequestValues,
} from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
export { redactResponseSecrets } from "./response";
export { assertCustomWidgetPathScope } from "./network-policy";
@@ -1,5 +1,4 @@
import { lookup } from "node:dns/promises";
import type { ConnectionOptions } from "node:tls";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import { Agent } from "undici";
@@ -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,42 +204,10 @@ 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,
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
});
}
export function assertCustomWidgetPathScope(url: URL, pathPrefix: string) {
const prefix = decodePath(pathPrefix).replace(/\/+$/u, "");
const path = decodePath(url.pathname);
if (
path.includes("\\") ||
path.split("/").some((segment) => segment === "." || segment === "..") ||
(prefix && path !== prefix && !path.startsWith(`${prefix}/`))
) {
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: "Request must remain within the selected integration URL",
});
}
}
function decodePath(value: string) {
let path = value;
for (let pass = 0; pass < 4; pass += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(path);
} catch {
return path;
}
if (decoded === path) return path;
path = decoded;
}
if (/%[0-9a-f]{2}/iu.test(path))
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path is excessively encoded" });
return path;
}
@@ -1,4 +1,3 @@
import { hasSameCustomWidgetSourceAuthentication } from "../core";
import { customWidgetDefinitionSchema } from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -37,9 +36,3 @@ export function validatePreviewTemplateRevision(current: PreviewTemplateContext,
}
return parsed.data.template;
}
export function canConfigurePreviewSource(current: CustomWidgetSource, source: CustomWidgetSource) {
if (current.type === "integration" && source.type === "integration")
return current.integrationKind === source.integrationKind;
return hasSameCustomWidgetSourceAuthentication(current, source);
}
@@ -1,4 +1,3 @@
import { canConfigurePreviewSource } from "./preview-session-template";
import { z } from "zod/v4";
import {
@@ -6,6 +5,7 @@ import {
customWidgetRequestsSchema,
customWidgetSecretKinds,
customWidgetSourcesSchema,
hasSameCustomWidgetSourceAuthentication,
} from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
@@ -242,7 +242,7 @@ export class CustomWidgetPreviewSessionService {
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!canConfigurePreviewSource(currentSource, source)) {
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
@@ -253,7 +253,7 @@ export class CustomWidgetPreviewSessionService {
...session,
sources: {
...session.sources,
[sourceId]: source,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
@@ -2,13 +2,11 @@ import { Buffer } from "node:buffer";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { applyAuth, performAuthenticatedRequest } from "./auth";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
import { applyAuth } from "./auth";
import { CustomWidgetDomainError } from "./errors";
import {
assertSafeStaticHeaders,
assertCustomWidgetPathScope,
createPinnedAgent,
resolveAndValidateHost,
resolveSameOriginTarget,
@@ -16,7 +14,6 @@ import {
} from "./network-policy";
import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { parseResponseBody } from "./response";
import { redactResponseSecrets } from "./response";
export {
assertSafeStaticHeaders,
@@ -40,6 +37,32 @@ export const MAX_REQUEST_DURATION_MS = 45_000;
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
export interface CustomWidgetAuthConfig {
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
}
export interface CustomWidgetHttpRequest {
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
@@ -52,8 +75,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
try {
assertRequest(input);
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
@@ -85,17 +107,12 @@ async function performRequestWithinDeadline(
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
let currentMethod = input.method;
let currentBody = input.auth?.transformBody ? input.auth.transformBody(input.body) : input.body;
assertRequest({ ...input, body: currentBody });
let currentBody = input.body;
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
input.auth?.transformUrl?.(currentUrl);
resolveSameOriginTarget(input.baseUrl, currentUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix);
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();
@@ -118,22 +135,15 @@ async function performRequestWithinDeadline(
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: normalizeResponseHeaders(responseData.headers),
});
const parsed = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
result = {
kind: "response",
response: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
},
};
if (input.auth?.isExpired?.(parsed)) {
parsed.ok = false;
parsed.status = 401;
parsed.statusText = "Integration session expired";
}
parsed.data = redactResponseSecrets(parsed.data, [
...(input.redactSecrets ?? []),
...(input.auth?.secrets ?? []),
]);
result = { kind: "response", response: parsed };
} else {
await responseData.body.dump();
result = {
@@ -238,8 +248,6 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | u
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value);
for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value);
}
return headers;
}
@@ -1,43 +0,0 @@
import type { ConnectionOptions } from "node:tls";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
export interface CustomWidgetAuthConfig {
baseUrl?: string;
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
headers?: Record<string, string>;
query?: Record<string, string>;
transformUrl?(url: URL): void;
transformBody?(body: string | undefined): string;
isExpired?(response: CustomWidgetHttpResponse): boolean;
refreshAsync?(): Promise<CustomWidgetAuthConfig>;
}
export interface CustomWidgetHttpRequest {
resolveConnectionAsync?(): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
>;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
redactSecrets?: CustomWidgetAuthConfig["secrets"];
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer";
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
@@ -64,39 +63,3 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
const tooLarge = () =>
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
if (secrets.length === 0) return data;
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);
}
@@ -22,7 +22,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age
describe("authoring resources", () => {
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.10.0" });
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.9.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
@@ -1,10 +1,9 @@
import type { ReactNode } from "react";
import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react";
import type {
CustomWidgetSecretKind,
CustomJsxNetworkScope,
CustomWidgetSource,
CustomWidgetSourceSetup,
CustomWidgetSourceUrlIssue,
} from "../core";
@@ -12,8 +11,7 @@ import { getCustomWidgetSourceSetupIssue } from "../core";
export interface CustomWidgetSourceSetupValue {
baseUrl: string;
networkScope: CustomJsxNetworkScope;
integrationId?: string;
networkScope: CustomWidgetSource["networkScope"];
urlConfirmed: boolean;
secrets: Partial<Record<CustomWidgetSecretKind, string>>;
}
@@ -40,7 +38,6 @@ export interface CustomWidgetSourceSetupPanelProps {
values: Record<string, CustomWidgetSourceSetupValue>;
messages: CustomWidgetSourceSetupMessages;
onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void;
renderIntegrationSource?(setup: CustomWidgetSourceSetup, value: CustomWidgetSourceSetupValue): ReactNode;
}
export function createCustomWidgetSourceSetupValues(
@@ -51,7 +48,6 @@ export function createCustomWidgetSourceSetupValues(
setup.sourceId,
{
baseUrl: setup.baseUrl,
integrationId: setup.integrationId,
networkScope: setup.networkScope,
urlConfirmed: !setup.requiresUrlConfirmation,
secrets: {} as Partial<Record<CustomWidgetSecretKind, string>>,
@@ -66,7 +62,6 @@ export function isCustomWidgetSourceSetupReady(
) {
return setups.every((setup) => {
const value = values[setup.sourceId];
if (setup.integrationKind) return Boolean(value?.integrationId);
return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed);
});
}
@@ -76,7 +71,6 @@ export function CustomWidgetSourceSetupPanel({
values,
messages,
onChange,
renderIntegrationSource,
}: CustomWidgetSourceSetupPanelProps) {
if (setups.length === 0) return null;
return (
@@ -90,14 +84,6 @@ export function CustomWidgetSourceSetupPanel({
{setups.map((setup) => {
const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId];
if (!value) return null;
if (setup.integrationKind) {
return (
<Stack key={setup.sourceId} gap="sm">
<Text fw={600}>{setup.sourceName}</Text>
{renderIntegrationSource?.(setup, value)}
</Stack>
);
}
const issue = getCustomWidgetSourceSetupIssue(value);
const missingCredentials = setup.credentialFields.filter(
(field) => !field.configured && !value.secrets[field.kind]?.trim(),
@@ -157,7 +143,7 @@ export function CustomWidgetSourceSetupPanel({
networkScope &&
onChange(setup.sourceId, {
...value,
networkScope: networkScope as CustomJsxNetworkScope,
networkScope: networkScope as CustomWidgetSource["networkScope"],
})
}
/>
-5
View File
@@ -725,8 +725,3 @@ export const integrationCategories = [
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
/** Integrations whose configured connection can serve HTTP requests. */
export function isHttpIntegrationKind(kind: string): kind is Exclude<IntegrationKind, "truenas" | "mock"> {
return Object.hasOwn(integrationDefs, kind) && kind !== "truenas" && kind !== "mock";
}
@@ -1,7 +1,6 @@
import { ParseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -11,10 +10,6 @@ import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-sum
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}` } };
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const headers = {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
@@ -4,8 +4,6 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { bearerAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -16,13 +14,11 @@ import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpd
const logger = createLogger({ module: "anchorIntegration" });
export class AnchorIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return bearerAuth(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -6,7 +6,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -37,10 +36,6 @@ interface WarriorSnapshotState {
}
export class ArchiveTeamWarriorIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.createAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/index.html"), {
headers: this.createAuthHeaders(),
@@ -1,7 +1,6 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -27,10 +26,6 @@ const mediaTypeCountField = {
>;
export class AudiobookshelfIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
@@ -1,9 +1,7 @@
import { isHttpIntegrationKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { clientAuthentication } from "../http-auth";
import type { Integration, IntegrationInput } from "./integration";
const logger = createLogger({ module: "integrationFactory" });
@@ -164,8 +162,3 @@ export async function createIntegrationAsync(
throw error;
}
}
export async function getIntegrationHttpAuthenticationAsync(integration: IntegrationInput & { kind: IntegrationKind }) {
if (!isHttpIntegrationKind(integration.kind)) throw new Error("This integration does not use HTTP");
return clientAuthentication(() => createIntegrationAsync(integration));
}
@@ -7,7 +7,6 @@ import { removeTrailingSlash } from "@homarr/common";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { HandleIntegrationErrors } from "./errors/decorator";
import { TestConnectionError } from "./test-connection/test-connection-error";
import type { TestingResult } from "./test-connection/test-connection-service";
@@ -37,8 +36,6 @@ export interface IntegrationTestingInput {
export abstract class Integration {
constructor(protected integration: IntegrationInput) {}
public abstract getHttpAuthenticationAsync(refresh?: boolean): Promise<IntegrationHttpAuthentication>;
public get publicIntegration() {
return {
id: this.integration.id,
@@ -1,6 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -9,10 +8,6 @@ import type { BazarrBadges } from "./bazarr-types";
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
export class BazarrIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/system/status"), {
headers: this.getAuthHeaders(),
@@ -125,12 +125,6 @@ export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
export class BeszelIntegration extends Integration {
public async getHttpAuthenticationAsync(refresh = false) {
if (refresh) await this.sessionStore.clearAsync();
const session = await this.authenticateAsync();
return { headers: { Authorization: session.token } };
}
private readonly sessionStore: SessionStore<BeszelSession>;
constructor(integration: IntegrationInput) {
@@ -1,7 +1,6 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -33,10 +32,6 @@ import {
} from "./coolify-types";
export class CoolifyIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthUrl = this.url("/api/health");
const healthResponse = await input.fetchAsync(healthUrl, {});
@@ -5,7 +5,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -15,10 +14,6 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return {};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/info"));
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -4,7 +4,6 @@ import type { fetch as undiciFetch } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
@@ -14,27 +13,6 @@ import type { DownloadClientItem } from "../../interfaces/downloads/download-cli
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
export class Aria2Integration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (!this.hasSecretValue("apiKey") || !this.getSecretValue("apiKey")) return {};
return {
transformBody: (body) => {
const authenticate = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(authenticate);
if (!value || typeof value !== "object") throw new Error("Expected a JSON-RPC request body");
const request = value as { method?: string; methodName?: string; params?: unknown[] };
const method = request.method ?? request.methodName;
const params = request.params ?? [];
if (!Array.isArray(params)) throw new Error("JSON-RPC params must be an array");
if (method === "system.multicall") return { ...request, params: [authenticate(params[0])] };
if (method === "system.listMethods" || method === "system.listNotifications") return request;
if (typeof params[0] === "string" && params[0].startsWith("token:")) params.shift();
return { ...request, params: [`token:${this.getSecretValue("apiKey")}`, ...params] };
};
return JSON.stringify(authenticate(JSON.parse(body ?? "null")));
},
};
}
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient();
const keys: (keyof Aria2Download)[] = [
@@ -17,15 +17,6 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync() {
const client = await this.getClientAsync();
await client.login();
if (!(await client.connected())) await client.connect();
const cookie = client.exportState().auth.cookieHeader;
if (!cookie) throw new Error("Integration authentication did not return a session");
return { headers: { Cookie: cookie } };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
@@ -4,8 +4,6 @@ import type { fetch as undiciFetch } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { basicAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
@@ -16,10 +14,6 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
import type { NzbGetClient } from "./nzbget-types";
export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return basicAuth()(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
return {
@@ -1,4 +1,3 @@
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
@@ -18,17 +17,6 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (this.hasSecretValue("apiKey")) return { headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` } };
const client = await this.getClientAsync();
const auth = client.exportState().auth;
if (!auth) return {};
return {
headers: { Cookie: `${auth.cookieName ?? "SID"}=${auth.sid}` },
isExpired: ({ status }) => status === 403,
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
@@ -5,8 +5,6 @@ import type { fetch as undiciFetch } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { queryAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
@@ -19,10 +17,6 @@ import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration);
export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return queryAuth("apikey")(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
//This is the one call that uses the least amount of data while requiring the api key
await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" });
@@ -6,8 +6,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -51,10 +49,6 @@ const logger = createLogger({ module: "slskd-integration" });
* Slskd integration cannot pause or stop downloads, it can only list downloads.
*/
export class SlskdIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
// eslint-disable-next-line no-restricted-syntax
public pauseQueueAsync(): Promise<void> {
return Promise.resolve();
@@ -82,7 +76,9 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v0/server"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-API-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) {
@@ -152,7 +148,9 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte
): Promise<unknown> {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
...options,
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-API-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) {
@@ -16,20 +16,6 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync() {
const client = await this.getClientAsync();
await client.getSession();
const session = client.exportState().auth?.sessionId;
if (!session) throw new Error("Integration authentication did not return a session");
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return {
headers: { Authorization: `Basic ${credentials}`, "X-Transmission-Session-Id": session },
isExpired: (response: { status: number }) => response.status === 409,
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
await client.getSession();
@@ -4,8 +4,6 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { headerAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -92,10 +90,6 @@ const userSchema = z.object({
});
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return headerAuth("X-Emby-Token")(this.integration);
}
private static readonly apiKeyHeader = "X-Emby-Token";
private static readonly deviceId = "homarr-emby-integration";
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
+1 -3
View File
@@ -1,3 +1 @@
export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator";
export type { IntegrationHttpAuthentication } from "./http-auth";
export { createIntegrationAsync } from "./base/creator";
@@ -4,7 +4,6 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
@@ -15,10 +14,6 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
import type { SystemHealthMonitoring } from "../types";
export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return {};
}
private readonly sessionStore: SessionStore<{ version: string }>;
constructor(integration: IntegrationInput) {
@@ -1,7 +1,6 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -17,10 +16,6 @@ import {
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = this.getAuthHeaders();
const url = this.url("/v1/vpn/status");
@@ -2,7 +2,6 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ImageProxy } from "@homarr/image-proxy";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -11,10 +10,6 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti
import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema";
export class GotifyIntegration extends Integration implements INotificationsIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getHeaders() };
}
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await input.fetchAsync(this.url("/health"), { headers: this.getHeaders() });
return { success: true };
@@ -5,7 +5,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -18,10 +17,6 @@ import { calendarEventSchema, calendarsSchema, entityStateSchema } from "./homea
const logger = createLogger({ module: "homeAssistantIntegration" });
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
public async getEntityStateAsync(entityId: string) {
try {
const response = await this.getAsync(`/api/states/${entityId}`);
-79
View File
@@ -1,79 +0,0 @@
import { Buffer } from "node:buffer";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationInput } from "./base/integration";
export interface IntegrationHttpAuthentication {
headers?: Record<string, string>;
redactValues?: string[];
query?: Record<string, string>;
baseUrl?: string;
transformUrl?(url: URL): void;
transformBody?(body: string | undefined): string;
isExpired?(response: { status: number; data: unknown }): boolean;
refreshAsync?(): Promise<IntegrationHttpAuthentication>;
}
function secret(input: IntegrationInput, kind: IntegrationSecretKind) {
const value = input.decryptedSecrets.find((entry) => entry.kind === kind)?.value;
if (!value) throw new Error(`Integration credential ${kind} is missing`);
return value;
}
export const headerAuth =
(name: string, prefix = "", kind: IntegrationSecretKind = "apiKey") =>
(input: IntegrationInput): IntegrationHttpAuthentication => ({
headers: { [name]: `${prefix}${secret(input, kind)}` },
});
export const bearerAuth = headerAuth("Authorization", "Bearer ");
export const apiKeyAuth = headerAuth("X-API-Key");
export const basicAuth =
(
username: IntegrationSecretKind = "username",
password: IntegrationSecretKind = "password",
headers: Record<string, string> = {},
) =>
(input: IntegrationInput): IntegrationHttpAuthentication => {
const credentials = Buffer.from(`${secret(input, username)}:${secret(input, password)}`).toString("base64");
return { headers: { ...headers, Authorization: `Basic ${credentials}` }, redactValues: [credentials] };
};
export const queryAuth =
(name: string, kind: IntegrationSecretKind = "apiKey") =>
(input: IntegrationInput): IntegrationHttpAuthentication => ({ query: { [name]: secret(input, kind) } });
export const clientAuthentication = async (
create: () => Promise<{ getHttpAuthenticationAsync(refresh?: boolean): Promise<IntegrationHttpAuthentication> }>,
) =>
withAuthTimeout(async () => {
const client = await create();
const resolve = async (refresh = false): Promise<IntegrationHttpAuthentication> => {
const auth = await client.getHttpAuthenticationAsync(refresh);
const values = [...Object.values(auth.headers ?? {}), ...Object.values(auth.query ?? {})];
for (const [name, value] of Object.entries(auth.headers ?? {})) {
if (name.toLowerCase() === "cookie") {
for (const cookie of value.split(";")) values.push(cookie.slice(cookie.indexOf("=") + 1).trim());
}
if (name.toLowerCase() === "authorization") values.push(value.slice(value.indexOf(" ") + 1));
}
return {
...auth,
redactValues: auth.redactValues ?? values,
refreshAsync: () => withAuthTimeout(() => resolve(true)),
};
};
return resolve();
});
async function withAuthTimeout<T>(resolve: () => T | Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(resolve),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("Integration authentication timed out")), 10_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
@@ -2,7 +2,6 @@ import ICAL from "ical.js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -11,20 +10,6 @@ import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integ
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
export class ICalIntegration extends Integration implements ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const baseUrl = this.getSecretValue("url");
const base = new URL(baseUrl);
return {
baseUrl,
redactValues: [...base.searchParams.values()],
transformUrl(url) {
// The stored feed URL is a complete endpoint, so the root request uses it exactly.
if (url.pathname === `${base.pathname}/`) url.pathname = base.pathname;
for (const [name, value] of base.searchParams) url.searchParams.set(name, value);
},
};
}
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
const result = await response.text();
@@ -17,8 +17,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ImageProxy } from "@homarr/image-proxy";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -50,10 +48,6 @@ const logger = createLogger({ module: "immich-integration" });
const carouselPhotoCount = 50;
export class ImmichIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
constructor(integration: IntegrationInput) {
super(integration);
}
@@ -60,11 +60,6 @@ export function parseLocation(remoteEndPoint: string | null | undefined): "lan"
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getHttpAuthenticationAsync() {
const api = await this.getApiAsync();
return { headers: { "X-Emby-Token": api.accessToken } };
}
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",
@@ -1,14 +1,8 @@
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { MediaAvailability } from "../interfaces/media-requests/media-request-types";
import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
export class JellyseerrIntegration extends OverseerrIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
protected override mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability {
// Availability statuses are not exactly the same between Jellyseerr and Overseerr (Jellyseerr has "blacklisted" additionally (deleted is the same value in overseerr))
if (availability === UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted) return "blacklisted";
@@ -1,7 +1,6 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -23,10 +22,6 @@ import {
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
export class LlamacppIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return {};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"), {
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
@@ -3,8 +3,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -16,13 +14,9 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "lidarrIntegration" });
export class LidarrIntegration extends Integration implements ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -45,7 +39,9 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
@@ -3,8 +3,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -19,10 +17,6 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "radarrIntegration" });
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
@@ -37,7 +31,9 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
@@ -126,7 +122,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
@@ -158,7 +154,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
@@ -192,7 +188,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -3,8 +3,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -16,13 +14,9 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "readarrIntegration" });
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -51,7 +45,9 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
@@ -3,8 +3,6 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -18,10 +16,6 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "sonarrIntegration" });
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
@@ -39,7 +33,9 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
@@ -123,7 +119,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
@@ -161,7 +157,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
@@ -197,7 +193,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -1,7 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -11,17 +9,12 @@ import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/med
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
export class TdarrIntegration extends Integration implements IMediaTranscodingIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (!this.hasSecretValue("apiKey") || !this.getSecretValue("apiKey")) return {};
return apiKeyAuth(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
method: "POST",
headers: {
accept: "application/json",
...(await this.getHttpAuthenticationAsync()).headers,
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
},
});
@@ -37,7 +30,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
const headerParams = {
accept: "application/json",
"Content-Type": "application/json",
...(await this.getHttpAuthenticationAsync()).headers,
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const response = await fetchWithTrustedCertificatesAsync(url, {
@@ -83,7 +76,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
const url = this.url("/api/v2/get-nodes");
const headerParams = {
"Content-Type": "application/json",
...(await this.getHttpAuthenticationAsync()).headers,
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "GET",
@@ -127,7 +120,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
const url = this.url("/api/v2/client/status-tables");
const headerParams = {
"Content-Type": "application/json",
...(await this.getHttpAuthenticationAsync()).headers,
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
@@ -166,7 +159,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
const url = this.url("/api/v2/client/status-tables");
const headerParams = {
"Content-Type": "application/json",
...(await this.getHttpAuthenticationAsync()).headers,
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
@@ -1,4 +1,3 @@
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -53,10 +52,6 @@ export class MockIntegration
ISmartHomeIntegration,
IUpsSummaryIntegration
{
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
throw new Error("This integration does not use HTTP");
}
private static readonly dnsHole = new DnsHoleMockService();
private static readonly calendar = new CalendarMockService();
private static readonly downloadClient = new DownloadClientMockService();
@@ -25,10 +25,6 @@ const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
public async getHttpAuthenticationAsync() {
return { query: this.getAuthParams(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
@@ -12,8 +12,6 @@ import { ResponseError } from "@homarr/common/server";
import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { basicAuth } from "../http-auth";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
@@ -59,10 +57,6 @@ dayjs.extend(timezone);
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return basicAuth("username", "password", { "OCS-APIRequest": "true" })(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options));
await client.login();
@@ -174,7 +168,12 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
public async getNotificationsAsync(): Promise<Notification[]> {
const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" });
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
Authorization: `Basic ${Buffer.from(
`${this.getSecretValue("username")}:${this.getSecretValue("password")}`,
).toString("base64")}`,
"OCS-APIRequest": "true",
},
});
if (!response.ok) throw new ResponseError(response);
@@ -1,7 +1,6 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -10,10 +9,6 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti
import { ntfyNotificationSchema } from "./ntfy-schema";
export class NTFYIntegration extends Integration implements INotificationsIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getHeaders() };
}
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() });
return { success: true };
@@ -22,7 +17,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
private getTopicURL() {
return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 });
}
private getHeaders(): Record<string, string> {
private getHeaders() {
return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {};
}
@@ -1,4 +1,3 @@
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
import { ResponseError } from "@homarr/common/server";
@@ -21,18 +20,6 @@ type SessionStoreValue =
| { type: "cookie"; loginToken: string; sessionId: string };
export class OpenMediaVaultIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(refresh = false): Promise<IntegrationHttpAuthentication> {
if (refresh) await this.sessionStore.clearAsync();
let session = await this.sessionStore.getAsync();
if (!session) {
session = await this.getSessionAsync();
await this.sessionStore.setAsync(session);
}
if (session.type === "cookie")
return { headers: { Cookie: `${session.loginToken.split(";")[0]}; ${session.sessionId.split(";")[0]}` } };
return { headers: { "X-OPENMEDIAVAULT-SESSIONID": session.sessionId } };
}
private readonly sessionStore: SessionStore<SessionStoreValue>;
constructor(integration: IntegrationInput) {
@@ -1,7 +1,6 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -23,10 +22,6 @@ import {
} from "./opnsense-types";
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: { Authorization: this.getAuthHeaders() } };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
headers: {
@@ -4,8 +4,6 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
@@ -42,13 +40,11 @@ export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
public async searchAsync(query: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const schemaData = await searchSchema.parseAsync(await response.json());
@@ -73,7 +69,9 @@ export class OverseerrIntegration
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const data = await mediaInformationSchema.parseAsync(await response.json());
const requestedSeasons = [
@@ -112,7 +110,9 @@ export class OverseerrIntegration
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/auth/me"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
@@ -126,14 +126,18 @@ export class OverseerrIntegration
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
const allRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, sort: "modified" }),
{
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
@@ -230,14 +234,18 @@ export class OverseerrIntegration
public async getStatsAsync(): Promise<RequestStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await getStatsSchema.parseAsync(await response.json());
}
public async getUsersAsync(): Promise<RequestUser[]> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const users = (await getUsersSchema.parseAsync(await response.json())).results;
return users.map((user): RequestUser => {
@@ -253,7 +261,9 @@ export class OverseerrIntegration
logger.info("Approving media request", { requestId, integration: this.integration.name });
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
@@ -264,7 +274,9 @@ export class OverseerrIntegration
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
@@ -272,7 +284,9 @@ export class OverseerrIntegration
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
headers: (await this.getHttpAuthenticationAsync()).headers,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) {
@@ -1,7 +1,6 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -10,10 +9,6 @@ import type { PaperlessNgxStats } from "./paperless-ngx-types";
import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types";
export class PaperlessNgxIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const statisticsUrl = this.url("/api/statistics/");
const statisticsResponse = await input.fetchAsync(statisticsUrl, {
@@ -1,7 +1,6 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -12,10 +11,6 @@ import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-ty
const STATS_REQUEST_TIMEOUT_MS = 10_000;
export class PatchMonIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
headers: this.getAuthHeaders(),
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more