Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4b6bf4b5 | ||
|
|
8157a43b9b | ||
|
|
901fd3d940 | ||
|
|
879a4ae23c | ||
|
|
d0529079ec | ||
|
|
c2a587bda7 | ||
|
|
52c82f72db |
No files matched your search
@@ -13,7 +13,7 @@ interface HomarrCustomWidgetV2 {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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?`:
|
`sources.default` is required. HTTP source properties: `name?`, `baseUrl`, `networkScope`, and `auth?`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -40,10 +40,12 @@ The object key `default` is the required source ID; `default` is not a property
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
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.
|
||||||
|
|
||||||
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.
|
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`.
|
||||||
|
|
||||||
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.
|
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
|
||||||
|
|
||||||
Every option has `label`, `control`, and `default`. Optional fields are `description`, `choices`, `choicesFrom`, `min`, `max`, `step`, `advanced`, and `group`.
|
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`.
|
||||||
@@ -3,10 +3,28 @@
|
|||||||
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
|
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,
|
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`,
|
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. Credentials belong to the widget but remain outside its manifest.
|
`basic`, an API-key header, or an API-key query parameter. HTTP source 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
|
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
|
||||||
request to a different origin.
|
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
|
## 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:
|
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,6 +32,7 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"
|
|||||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||||
|
import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions";
|
||||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||||
import {
|
import {
|
||||||
githubAvatarUrl,
|
githubAvatarUrl,
|
||||||
@@ -96,7 +97,15 @@ const isNotFound = (caught: unknown) =>
|
|||||||
|
|
||||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||||
|
|
||||||
const sourceHost = (baseUrl: string) => {
|
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;
|
||||||
try {
|
try {
|
||||||
return new URL(baseUrl).host;
|
return new URL(baseUrl).host;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -104,26 +113,30 @@ const sourceHost = (baseUrl: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceAuthLabel = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => {
|
const sourceAuthLabel = (source: WidgetSource) => {
|
||||||
if (typeof auth === "string")
|
if (source.type === "integration") return "Uses stored integration credentials";
|
||||||
return auth === "none" ? "No credentials" : auth === "basic" ? "Basic auth" : "Bearer token";
|
const { auth } = source;
|
||||||
return auth.type === "apiKeyHeader" ? `API key header · ${auth.name}` : `API key query · ${auth.name}`;
|
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 sourceNeedsSecret = (auth: HomarrCustomWidgetV2["sources"][string]["auth"]) => auth !== "none";
|
const sourceNeedsSecret = (source: WidgetSource) => source.type === "integration" || source.auth !== "none";
|
||||||
|
|
||||||
const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
|
const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
|
||||||
const sources = Object.entries(widget.sources);
|
const sources = Object.entries(widget.sources);
|
||||||
const requests = Object.values(widget.requests);
|
const requests = Object.values(widget.requests);
|
||||||
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
|
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
|
||||||
const queryCount = requests.filter((request) => request.kind === "query").length;
|
const queryCount = requests.filter((request) => request.kind === "query").length;
|
||||||
const actionCount = requests.filter((request) => request.kind === "action").length;
|
const actionCount = requests.filter((request) => request.kind === "action").length;
|
||||||
const credentialLabel =
|
const credentialLabel =
|
||||||
protectedSources.length === 0
|
protectedSources.length === 0
|
||||||
? "None required"
|
? "None required"
|
||||||
: protectedSources.length === 1
|
: protectedSources.length === 1
|
||||||
? sourceAuthLabel(protectedSources[0][1].auth)
|
? sourceAuthLabel(protectedSources[0][1])
|
||||||
: `${protectedSources.length} credentials required`;
|
: `${protectedSources.length} authenticated sources`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -152,9 +165,9 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
|
|||||||
<dd className="mt-1.5 space-y-1.5">
|
<dd className="mt-1.5 space-y-1.5">
|
||||||
{sources.slice(0, 2).map(([id, source]) => (
|
{sources.slice(0, 2).map(([id, source]) => (
|
||||||
<span key={id} className="flex min-w-0 items-center justify-between gap-2">
|
<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.baseUrl)}</span>
|
<span className="truncate font-mono text-xs font-medium">{sourceHost(source)}</span>
|
||||||
<Badge variant="secondary" className="shrink-0 font-normal">
|
<Badge variant="secondary" className="shrink-0 font-normal">
|
||||||
{source.networkScope}
|
{source.networkScope ?? "integration"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -536,7 +549,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
|||||||
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
|
const sources = widgetDefinition ? Object.entries(widgetDefinition.sources) : [];
|
||||||
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
|
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
|
||||||
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
|
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
|
||||||
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
|
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source));
|
||||||
const displayedContent = formatWorkshopContent(submission.type, submission.content);
|
const displayedContent = formatWorkshopContent(submission.type, submission.content);
|
||||||
const socialTitle = `${submission.title} · Homarr Workshop`;
|
const socialTitle = `${submission.title} · Homarr Workshop`;
|
||||||
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
|
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
|
||||||
@@ -779,11 +792,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
|||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="truncate font-medium">{source.name || id}</span>
|
<span className="truncate font-medium">{source.name || id}</span>
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{source.networkScope}
|
{source.networkScope ?? "integration"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">{sourceHost(source.baseUrl)}</p>
|
<p className="truncate text-xs text-muted-foreground">{sourceHost(source)}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source.auth)}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{sourceAuthLabel(source)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
-22
@@ -16,6 +16,9 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
|
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";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface RequestDetails {
|
interface RequestDetails {
|
||||||
@@ -24,10 +27,7 @@ interface RequestDetails {
|
|||||||
kinds: Array<"apiKey" | "username" | "password">;
|
kinds: Array<"apiKey" | "username" | "password">;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
status: "pending" | "completed";
|
status: "pending" | "completed";
|
||||||
source: {
|
source: CustomWidgetSource;
|
||||||
baseUrl: string;
|
|
||||||
networkScope: "public" | "private" | "loopback";
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
||||||
@@ -35,6 +35,7 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
|||||||
const tSecret = useI18n("customWidget.secret");
|
const tSecret = useI18n("customWidget.secret");
|
||||||
const [details, setDetails] = useState<RequestDetails | null>(null);
|
const [details, setDetails] = useState<RequestDetails | null>(null);
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [integrationId, setIntegrationId] = useState<string>();
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
|
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,8 +48,9 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
|||||||
const body = (await response.json()) as RequestDetails | { error: 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"));
|
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
|
||||||
setDetails(body);
|
setDetails(body);
|
||||||
setBaseUrl(body.source.baseUrl);
|
setBaseUrl(body.source.baseUrl ?? "");
|
||||||
setNetworkScope(body.source.networkScope);
|
setNetworkScope(body.source.networkScope ?? "public");
|
||||||
|
setIntegrationId(body.source.integrationId);
|
||||||
})
|
})
|
||||||
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
|
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -58,10 +60,12 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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)}`, {
|
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
|
body: JSON.stringify(configuration),
|
||||||
});
|
});
|
||||||
const body = (await response.json()) as { status?: string; error?: string };
|
const body = (await response.json()) as { status?: string; error?: string };
|
||||||
if (!response.ok) throw new Error(body.error ?? t("saveError"));
|
if (!response.ok) throw new Error(body.error ?? t("saveError"));
|
||||||
@@ -102,20 +106,31 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
|||||||
{t("source", { name: details.sourceName })}
|
{t("source", { name: details.sourceName })}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<TextInput
|
{details.source.type === "integration" && (
|
||||||
label={t("baseUrl")}
|
<IntegrationSourceSelect
|
||||||
type="url"
|
kind={details.source.integrationKind}
|
||||||
value={baseUrl}
|
integrationId={integrationId}
|
||||||
onChange={(event) => setBaseUrl(event.currentTarget.value)}
|
onChange={setIntegrationId}
|
||||||
required
|
/>
|
||||||
/>
|
)}
|
||||||
<Select
|
{details.source.type !== "integration" && (
|
||||||
label={t("networkScope")}
|
<>
|
||||||
data={["public", "private", "loopback"]}
|
<TextInput
|
||||||
value={networkScope}
|
label={t("baseUrl")}
|
||||||
allowDeselect={false}
|
type="url"
|
||||||
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
|
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) => {
|
{details.kinds.map((kind) => {
|
||||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +147,10 @@ export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
|
|||||||
})}
|
})}
|
||||||
<Button
|
<Button
|
||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
|
disabled={
|
||||||
|
(details.source.type === "integration" ? !integrationId : !URL.canParse(baseUrl)) ||
|
||||||
|
details.kinds.some((kind) => !values[kind])
|
||||||
|
}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
>
|
>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
|
|||||||
+122
-65
@@ -4,9 +4,15 @@ import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from
|
|||||||
import { IconKey, IconTrash } from "@tabler/icons-react";
|
import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
import type {
|
||||||
|
CustomWidgetHttpSource,
|
||||||
|
CustomWidgetSource,
|
||||||
|
CustomWidgetSourceUrlIssue,
|
||||||
|
} from "@homarr/custom-widgets/core";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { IntegrationSourceSelect } from "~/components/custom-widgets/integration-source-select";
|
||||||
|
|
||||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||||
|
|
||||||
@@ -52,9 +58,6 @@ export function CustomWidgetSourceField({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: CustomWidgetSourceFieldProps) {
|
}: CustomWidgetSourceFieldProps) {
|
||||||
const t = useI18n("customWidget.workbench.sources");
|
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 (
|
return (
|
||||||
<Fieldset legend={index === 0 ? t("primary") : source.name}>
|
<Fieldset legend={index === 0 ? t("primary") : source.name}>
|
||||||
@@ -73,72 +76,41 @@ export function CustomWidgetSourceField({
|
|||||||
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
<Select
|
||||||
label={t("baseUrl")}
|
label={t("sourceType")}
|
||||||
type="url"
|
data={[
|
||||||
value={source.baseUrl}
|
{ value: "http", label: t("manualSource") },
|
||||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
{ value: "integration", label: t("existingIntegration") },
|
||||||
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
|
]}
|
||||||
|
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" });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Group grow align="start">
|
{source.type === "integration" && (
|
||||||
<Select
|
<IntegrationSourceSelect
|
||||||
label={t("networkScope")}
|
kind={source.integrationKind}
|
||||||
data={["public", "private", "loopback"]}
|
integrationId={source.integrationId}
|
||||||
value={source.networkScope}
|
onChange={(integrationId) => onUpdate(index, { integrationId })}
|
||||||
onChange={(value) =>
|
onKindChange={(integrationKind) => onUpdate(index, { integrationKind, integrationId: undefined })}
|
||||||
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" && (
|
{source.type !== "integration" && (
|
||||||
<TextInput
|
<CustomWidgetHttpSourceFields
|
||||||
label={t("queryParameter")}
|
source={source}
|
||||||
value={source.auth.name}
|
index={index}
|
||||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
|
form={form}
|
||||||
|
definitionId={definitionId}
|
||||||
|
clearSecretPending={clearSecretPending}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onSetAuthentication={onSetAuthentication}
|
||||||
|
onSetSecret={onSetSecret}
|
||||||
|
onClearSecret={onClearSecret}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(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 && (
|
{index > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,3 +126,88 @@ export function CustomWidgetSourceField({
|
|||||||
</Fieldset>
|
</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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-1
@@ -28,6 +28,12 @@ export function CustomWidgetSourcesEditor({
|
|||||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||||
const sources = parseSources(form.values.sources);
|
const sources = parseSources(form.values.sources);
|
||||||
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
|
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 previousId = sources[index]?.id;
|
||||||
const nextId = changes.id ?? previousId;
|
const nextId = changes.id ?? previousId;
|
||||||
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
|
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
|
||||||
@@ -42,7 +48,7 @@ export function CustomWidgetSourcesEditor({
|
|||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
sourceEntries.map(([id, source], i) => [
|
sourceEntries.map(([id, source], i) => [
|
||||||
i === index ? nextId : id,
|
i === index ? nextId : id,
|
||||||
i === index ? { ...source, ...changes, id: undefined } : source,
|
i === index ? { ...(changes.type ? { name: source.name } : source), ...changes, id: undefined } : source,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,7 @@ import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconF
|
|||||||
|
|
||||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||||
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
|
import { ImportReviewContent } from "@homarr/custom-widgets/workbench";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
||||||
@@ -190,7 +191,7 @@ export function WorkshopDetail({ id }: { id: string }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
|
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
|
||||||
<CustomWidgetSourceSetupPanel
|
<SourceSetupPanel
|
||||||
setups={importer.setups}
|
setups={importer.setups}
|
||||||
values={importer.values}
|
values={importer.values}
|
||||||
onChange={importer.setValue}
|
onChange={importer.setValue}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
claimCustomWidgetConfigurationRequest,
|
claimCustomWidgetConfigurationRequest,
|
||||||
|
assertCustomWidgetIntegrationBindings,
|
||||||
completeCustomWidgetConfigurationRequest,
|
completeCustomWidgetConfigurationRequest,
|
||||||
configureCustomWidgetSourceFromRequest,
|
configureCustomWidgetSourceFromRequest,
|
||||||
configurePreviewSessionSource,
|
configurePreviewSessionSource,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
|
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
|
||||||
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
|
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
|
||||||
import { db } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
|
||||||
import { adminRoute } from "../../admin";
|
import { adminRoute } from "../../admin";
|
||||||
import { readConfigurationRequestBody } from "../body";
|
import { readConfigurationRequestBody } from "../body";
|
||||||
@@ -52,17 +54,40 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
|
|||||||
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
|
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
|
||||||
}
|
}
|
||||||
const body = parsedBody.data;
|
const body = parsedBody.data;
|
||||||
const sourceResult = customWidgetSourceSchema.safeParse({
|
let candidate: unknown;
|
||||||
...pending.source,
|
if (pending.source.type === "integration") {
|
||||||
baseUrl: body.baseUrl,
|
if (
|
||||||
networkScope: body.networkScope,
|
!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);
|
||||||
if (!sourceResult.success) {
|
if (!sourceResult.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
|
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
|
||||||
{ status: 400 },
|
{ 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 secrets = pending.kinds.flatMap((kind) => {
|
||||||
const value = body.secrets[kind];
|
const value = body.secrets[kind];
|
||||||
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
||||||
@@ -95,6 +120,7 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
|
|||||||
const result = await configureCustomWidgetSourceFromRequest(db, {
|
const result = await configureCustomWidgetSourceFromRequest(db, {
|
||||||
definitionId: claimed.target.id,
|
definitionId: claimed.target.id,
|
||||||
sourceId: claimed.sourceId,
|
sourceId: claimed.sourceId,
|
||||||
|
integrationId: sourceResult.data.integrationId,
|
||||||
baseUrl: sourceResult.data.baseUrl,
|
baseUrl: sourceResult.data.baseUrl,
|
||||||
networkScope: sourceResult.data.networkScope,
|
networkScope: sourceResult.data.networkScope,
|
||||||
secrets,
|
secrets,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
|
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
|
||||||
const configurationRequestBodySchema = z.strictObject({
|
const configurationRequestBodySchema = z
|
||||||
baseUrl: z.string(),
|
.strictObject({
|
||||||
networkScope: z.enum(["public", "private", "loopback"]),
|
baseUrl: z.string().optional(),
|
||||||
secrets: z.record(z.string(), z.string()),
|
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",
|
||||||
|
);
|
||||||
|
|
||||||
export async function readConfigurationRequestBody(
|
export async function readConfigurationRequestBody(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
|
||||||
import type { ModalProps } from "@mantine/core";
|
import type { ModalProps } from "@mantine/core";
|
||||||
|
|
||||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||||
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useCustomWidgetImport } from "./use-custom-widget-import";
|
import { useCustomWidgetImport } from "./use-custom-widget-import";
|
||||||
@@ -67,7 +68,7 @@ export function CustomWidgetImportDialog({
|
|||||||
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
|
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CustomWidgetSourceSetupPanel
|
<SourceSetupPanel
|
||||||
setups={importer.setups}
|
setups={importer.setups}
|
||||||
values={importer.values}
|
values={importer.values}
|
||||||
onChange={importer.setValue}
|
onChange={importer.setValue}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
|
||||||
@@ -8,11 +9,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
|
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
|
||||||
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
|
||||||
import {
|
import { createCustomWidgetSourceSetupValues, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
|
||||||
createCustomWidgetSourceSetupValues,
|
|
||||||
CustomWidgetSourceSetupPanel,
|
|
||||||
isCustomWidgetSourceSetupReady,
|
|
||||||
} from "@homarr/custom-widgets/workbench";
|
|
||||||
import type { CustomWidgetSourceSetupMessages, CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
|
import type { CustomWidgetSourceSetupMessages, CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -68,6 +65,14 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
|
|||||||
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||||
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: 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({
|
await configureMutation.mutateAsync({
|
||||||
definitionId,
|
definitionId,
|
||||||
sourceId: setup.sourceId,
|
sourceId: setup.sourceId,
|
||||||
@@ -109,7 +114,7 @@ export function CustomWidgetSourceSetupDialog({ definitionId, opened, onClose }:
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<CustomWidgetSourceSetupPanel
|
<SourceSetupPanel
|
||||||
setups={setups}
|
setups={setups}
|
||||||
values={values}
|
values={values}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"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.fromEntries(
|
||||||
Object.entries(values).map(([sourceId, value]) => [
|
Object.entries(values).map(([sourceId, value]) => [
|
||||||
sourceId,
|
sourceId,
|
||||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
{ baseUrl: value.baseUrl, networkScope: value.networkScope, integrationId: value.integrationId },
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
interface PublishableWorkshopDefinition {
|
interface PublishableWorkshopDefinition {
|
||||||
sources: Record<string, { name?: string; networkScope: string }>;
|
sources: Record<string, { name?: string; networkScope?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serializeWorkshopDefinition = (definition: unknown): string => {
|
export const serializeWorkshopDefinition = (definition: unknown): string => {
|
||||||
@@ -29,5 +29,5 @@ export async function publishWorkshopDefinition({
|
|||||||
|
|
||||||
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
|
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
|
||||||
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
|
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
|
||||||
source.networkScope === "public" ? [] : [source.name ?? sourceId],
|
source.networkScope === undefined || source.networkScope === "public" ? [] : [source.name ?? sourceId],
|
||||||
);
|
);
|
||||||
@@ -5,3 +5,5 @@ export {
|
|||||||
serializeCustomWidgetDefinition,
|
serializeCustomWidgetDefinition,
|
||||||
} from "./router/custom-widget/stored-definition";
|
} from "./router/custom-widget/stored-definition";
|
||||||
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
|
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
|
||||||
|
|
||||||
|
export { assertCustomWidgetIntegrationBindings } from "./router/custom-widget/source-resolver";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const creationProcedures = {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { secrets, ...candidate } = input;
|
const { secrets, ...candidate } = input;
|
||||||
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
|
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
|
||||||
assertSecretSources(definition.sources, secrets);
|
assertSecretSources(definition.sources, secrets);
|
||||||
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
||||||
logger.info("Created custom widget definition", { id, name: definition.name });
|
logger.info("Created custom widget definition", { id, name: definition.name });
|
||||||
@@ -115,6 +117,7 @@ export const creationProcedures = {
|
|||||||
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
|
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
|
||||||
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
|
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
|
||||||
);
|
);
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
|
||||||
assertSecretSources(definition.sources, secrets);
|
assertSecretSources(definition.sources, secrets);
|
||||||
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
||||||
logger.info("Created custom widget definition from tested preview", {
|
logger.info("Created custom widget definition from tested preview", {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
|
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ export const customWidgetRouter = createTRPCRouter({
|
|||||||
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
|
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
|
||||||
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||||
});
|
});
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
|
||||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||||
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
||||||
const secretRows = secrets?.map((secret) => ({
|
const secretRows = secrets?.map((secret) => ({
|
||||||
@@ -106,7 +109,7 @@ export const customWidgetRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
|
||||||
const where = and(
|
const where = and(
|
||||||
eq(schema.customWidgetSecrets.definitionId, id),
|
eq(schema.customWidgetSecrets.definitionId, id),
|
||||||
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
||||||
@@ -151,7 +154,7 @@ export const customWidgetRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
||||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
|
||||||
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
||||||
transaction
|
transaction
|
||||||
.delete(customWidgetSecrets)
|
.delete(customWidgetSecrets)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveCustomWidgetSource } from "./source-resolver";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -153,31 +154,19 @@ export const managementQueryProcedures = {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const values = resolveOptionRequestValues(request, input.params);
|
const values = resolveOptionRequestValues(request, input.params);
|
||||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
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 response = await executeCustomWidgetRequest({
|
const response = await executeCustomWidgetRequest({
|
||||||
baseUrl: source.baseUrl,
|
...connection,
|
||||||
targetUrl: renderRequestTarget(source.baseUrl, request, values),
|
targetUrl: renderRequestTarget(connection.baseUrl, request, values),
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body: renderRequestBody(request, values),
|
body: renderRequestBody(request, values),
|
||||||
staticHeaders: request.headers,
|
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",
|
kind: "query",
|
||||||
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}`,
|
cacheKey: `custom-widget:options:${input.definitionId}:${getCustomWidgetCacheVersion(stored)}:${request.id}:${hashRuntimeParams(values)}:${connection.cacheVersion}`,
|
||||||
cacheTtlSeconds: request.cacheSeconds ?? 30,
|
cacheTtlSeconds: request.cacheSeconds ?? 30,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
@@ -50,12 +51,12 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
|
|||||||
name: widget.name,
|
name: widget.name,
|
||||||
description: widget.description,
|
description: widget.description,
|
||||||
iconUrl: widget.iconUrl,
|
iconUrl: widget.iconUrl,
|
||||||
sources: Object.entries(widget.sources).map(([id, { name, baseUrl, networkScope, auth }]) => ({
|
sources: Object.entries(widget.sources).map(([id, source]) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name: source.name,
|
||||||
origin: new URL(baseUrl).origin,
|
origin: source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
|
||||||
networkScope,
|
networkScope: source.networkScope,
|
||||||
authType: typeof auth === "string" ? auth : auth.type,
|
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
|
||||||
})),
|
})),
|
||||||
requestCount: Object.keys(widget.requests).length,
|
requestCount: Object.keys(widget.requests).length,
|
||||||
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
missingSecrets: getCustomWidgetSecretRequirements(widget.sources).filter(
|
||||||
@@ -110,11 +111,11 @@ export function mapAvailableCustomWidget(definition: StoredDefinition) {
|
|||||||
options: widget.options,
|
options: widget.options,
|
||||||
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
defaultOptions: getCustomWidgetDefaultOptions(widget.options),
|
||||||
template: widget.template,
|
template: widget.template,
|
||||||
sources: Object.entries(widget.sources).map(([id, { name, networkScope, auth }]) => ({
|
sources: Object.entries(widget.sources).map(([id, source]) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name: source.name,
|
||||||
networkScope,
|
networkScope: source.networkScope,
|
||||||
authType: typeof auth === "string" ? auth : auth.type,
|
authType: source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
|
||||||
})),
|
})),
|
||||||
requestCapabilities: Object.entries(widget.requests).map(
|
requestCapabilities: Object.entries(widget.requests).map(
|
||||||
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export const previewActionProcedures = {
|
|||||||
if (definition?.kind !== "action")
|
if (definition?.kind !== "action")
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
|
||||||
const request = { id: input.requestId, ...definition };
|
const request = { id: input.requestId, ...definition };
|
||||||
const resolved = getPreviewRequestSource(session, request.source);
|
const resolved = await getPreviewRequestSource(ctx, session, request);
|
||||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||||
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
|
const params = resolveCustomWidgetRequestValues(request, session.options, input.params);
|
||||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
|
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
|
||||||
const body = renderRequestBody(request, params);
|
const body = renderRequestBody(request, params);
|
||||||
if (!session.liveActions) {
|
if (!session.liveActions) {
|
||||||
await recordPreviewJournal(session, {
|
await recordPreviewJournal(session, {
|
||||||
@@ -66,13 +66,12 @@ export const previewActionProcedures = {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const response = await executeCustomWidgetRequest({
|
const response = await executeCustomWidgetRequest({
|
||||||
baseUrl: resolved.source.baseUrl,
|
...resolved,
|
||||||
targetUrl,
|
targetUrl,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body,
|
body,
|
||||||
staticHeaders: request.headers,
|
staticHeaders: request.headers,
|
||||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||||
networkScope: resolved.source.networkScope,
|
|
||||||
kind: "action",
|
kind: "action",
|
||||||
});
|
});
|
||||||
if (response.ok && request.invalidates?.length) {
|
if (response.ok && request.invalidates?.length) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
|
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
|||||||
const definition = parseCustomWidgetAuthoringInput(() =>
|
const definition = parseCustomWidgetAuthoringInput(() =>
|
||||||
normalizeCustomWidgetAuthoringDefinition(input.definition),
|
normalizeCustomWidgetAuthoringDefinition(input.definition),
|
||||||
);
|
);
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, definition.sources);
|
||||||
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
|
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
|
||||||
const optionIssues = validateCustomWidgetOptions(definition.options, options);
|
const optionIssues = validateCustomWidgetOptions(definition.options, options);
|
||||||
if (optionIssues.length > 0) {
|
if (optionIssues.length > 0) {
|
||||||
@@ -115,7 +118,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
|||||||
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
|
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
|
||||||
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
|
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
|
||||||
|
|
||||||
const authType = typeof submittedSource.auth === "string" ? submittedSource.auth : submittedSource.auth.type;
|
const authType = getCustomWidgetSourceAuthType(submittedSource);
|
||||||
const missingReplacement = requiredSecretKinds(authType).some(
|
const missingReplacement = requiredSecretKinds(authType).some(
|
||||||
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
|
(kind) => !secrets.some((secret) => secret.sourceId === sourceId && secret.kind === kind),
|
||||||
);
|
);
|
||||||
@@ -142,7 +145,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
|||||||
|
|
||||||
const invalid = secrets.find((secret) => {
|
const invalid = secrets.find((secret) => {
|
||||||
const source = definition.sources[secret.sourceId];
|
const source = definition.sources[secret.sourceId];
|
||||||
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
const authType = getCustomWidgetSourceAuthType(source);
|
||||||
const kinds =
|
const kinds =
|
||||||
authType === "basic"
|
authType === "basic"
|
||||||
? ["username", "password"]
|
? ["username", "password"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveCustomWidgetSource } from "./source-resolver";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -33,25 +34,15 @@ export const recordPreviewJournal = async (...args: Parameters<typeof appendPrev
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPreviewRequestSource = (session: CustomWidgetPreviewSession, sourceId: string) => {
|
export async function getPreviewRequestSource(
|
||||||
const source = session.sources[sourceId];
|
ctx: Parameters<typeof resolveCustomWidgetSource>[0],
|
||||||
|
session: CustomWidgetPreviewSession,
|
||||||
|
request: CustomJsxRequest,
|
||||||
|
) {
|
||||||
|
const source = session.sources[request.source];
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
return resolveCustomWidgetSource(ctx, source, request, () => getPreviewSessionSecrets(session, request.source));
|
||||||
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(
|
export function resolvePreviewRequestParams(
|
||||||
request: CustomJsxRequest,
|
request: CustomJsxRequest,
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ export const previewQueryProcedures = {
|
|||||||
if (definition?.kind !== "query")
|
if (definition?.kind !== "query")
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
|
||||||
const request = { id: input.requestId, ...definition };
|
const request = { id: input.requestId, ...definition };
|
||||||
const resolved = getPreviewRequestSource(session, request.source);
|
const resolved = await getPreviewRequestSource(ctx, session, request);
|
||||||
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||||
const params = resolvePreviewRequestParams(request, session.options, input.params);
|
const params = resolvePreviewRequestParams(request, session.options, input.params);
|
||||||
const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
|
const targetUrl = renderRequestTarget(resolved.baseUrl, request, params);
|
||||||
const body = renderRequestBody(request, params);
|
const body = renderRequestBody(request, params);
|
||||||
const release = await acquireCustomWidgetRequestLimit({
|
const release = await acquireCustomWidgetRequestLimit({
|
||||||
category: "query",
|
category: "query",
|
||||||
@@ -52,15 +52,14 @@ export const previewQueryProcedures = {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const response = await executeCustomWidgetRequest({
|
const response = await executeCustomWidgetRequest({
|
||||||
baseUrl: resolved.source.baseUrl,
|
...resolved,
|
||||||
targetUrl,
|
targetUrl,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body,
|
body,
|
||||||
staticHeaders: request.headers,
|
staticHeaders: request.headers,
|
||||||
auth: request.auth === "none" ? undefined : resolved.auth,
|
auth: request.auth === "none" ? undefined : resolved.auth,
|
||||||
networkScope: resolved.source.networkScope,
|
|
||||||
kind: "query",
|
kind: "query",
|
||||||
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
|
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}:${resolved.cacheVersion}`,
|
||||||
cacheTtlSeconds: request.cacheSeconds,
|
cacheTtlSeconds: request.cacheSeconds,
|
||||||
});
|
});
|
||||||
await recordPreviewJournal(session, {
|
await recordPreviewJournal(session, {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number];
|
|||||||
type ConfigureCustomWidgetSourceInput = {
|
type ConfigureCustomWidgetSourceInput = {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
baseUrl: string;
|
baseUrl?: string;
|
||||||
|
integrationId?: string;
|
||||||
networkScope?: CustomWidgetSource["networkScope"];
|
networkScope?: CustomWidgetSource["networkScope"];
|
||||||
secrets: CustomWidgetCreateInput["secrets"];
|
secrets: CustomWidgetCreateInput["secrets"];
|
||||||
expectedSource?: CustomWidgetSource;
|
expectedSource?: CustomWidgetSource;
|
||||||
@@ -220,11 +221,17 @@ function prepareSourceUpdate(
|
|||||||
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
|
if (input.expectedSource && !hasSameCustomWidgetSourceAuthentication(previousSource, input.expectedSource)) {
|
||||||
throw new SourceConfigurationError("binding-changed");
|
throw new SourceConfigurationError("binding-changed");
|
||||||
}
|
}
|
||||||
const source = customWidgetSourceSchema.parse({
|
let configuration: unknown;
|
||||||
...previousSource,
|
if (previousSource.type === "integration") {
|
||||||
baseUrl: input.baseUrl,
|
configuration = { ...previousSource, integrationId: input.integrationId };
|
||||||
networkScope: input.networkScope ?? previousSource.networkScope,
|
} else {
|
||||||
});
|
configuration = {
|
||||||
|
...previousSource,
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
networkScope: input.networkScope ?? previousSource.networkScope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const source = customWidgetSourceSchema.parse(configuration);
|
||||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function requiredSecretKinds(authType: string) {
|
|||||||
* origin, while the network scope and auth destination are part of the binding.
|
* origin, while the network scope and auth destination are part of the binding.
|
||||||
*/
|
*/
|
||||||
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
|
export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidgetSource) {
|
||||||
|
if (left.type === "integration" || right.type === "integration") return false;
|
||||||
return (
|
return (
|
||||||
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
|
new URL(left.baseUrl).origin === new URL(right.baseUrl).origin &&
|
||||||
left.networkScope === right.networkScope &&
|
left.networkScope === right.networkScope &&
|
||||||
@@ -24,12 +25,12 @@ export function hasSameSecretBinding(left: CustomWidgetSource, right: CustomWidg
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assertSecretSources(
|
export function assertSecretSources(
|
||||||
sources: Record<string, { auth: string | { type: string } }>,
|
sources: Record<string, { auth?: string | { type: string }; type?: string }>,
|
||||||
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
secrets: readonly { sourceId: string; kind: "apiKey" | "username" | "password" }[],
|
||||||
) {
|
) {
|
||||||
const invalid = secrets.find((secret) => {
|
const invalid = secrets.find((secret) => {
|
||||||
const source = sources[secret.sourceId];
|
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);
|
return !source || !authType || !new Set<string>(requiredSecretKinds(authType)).has(secret.kind);
|
||||||
});
|
});
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
|
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -58,12 +60,19 @@ export const secretProcedures = {
|
|||||||
|
|
||||||
sourceConfigure: permissionRequiredProcedure
|
sourceConfigure: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.meta({ mcp: { enabled: true, description: "Configure one custom widget API source and its credentials." } })
|
.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.",
|
||||||
|
},
|
||||||
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
definitionId: z.string(),
|
definitionId: z.string(),
|
||||||
sourceId: z.string(),
|
sourceId: z.string(),
|
||||||
baseUrl: z.string(),
|
baseUrl: z.string().optional(),
|
||||||
|
integrationId: z.string().min(1).max(100).optional(),
|
||||||
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
||||||
secrets: customWidgetSecretsInputSchema.default([]),
|
secrets: customWidgetSecretsInputSchema.default([]),
|
||||||
}),
|
}),
|
||||||
@@ -76,22 +85,34 @@ export const secretProcedures = {
|
|||||||
const definition = parseStoredCustomWidgetDefinition(stored);
|
const definition = parseStoredCustomWidgetDefinition(stored);
|
||||||
const current = definition.sources[input.sourceId];
|
const current = definition.sources[input.sourceId];
|
||||||
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||||
const source = customWidgetSourceSchema.parse({
|
let configuration: unknown;
|
||||||
...current,
|
if (current.type === "integration") {
|
||||||
baseUrl: input.baseUrl,
|
if (
|
||||||
networkScope: input.networkScope ?? current.networkScope,
|
!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 });
|
||||||
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
assertSecretSources({ [input.sourceId]: source }, input.secrets);
|
||||||
const configuredSource = await configureCustomWidgetSource(ctx.db, {
|
const configuredSource = await configureCustomWidgetSource(ctx.db, { ...input, expectedSource: current });
|
||||||
definitionId: input.definitionId,
|
|
||||||
sourceId: input.sourceId,
|
|
||||||
baseUrl: source.baseUrl,
|
|
||||||
networkScope: input.networkScope,
|
|
||||||
secrets: input.secrets,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
definitionId: input.definitionId,
|
definitionId: input.definitionId,
|
||||||
sourceId: input.sourceId,
|
sourceId: input.sourceId,
|
||||||
|
integrationId: configuredSource.integrationId,
|
||||||
baseUrl: configuredSource.baseUrl,
|
baseUrl: configuredSource.baseUrl,
|
||||||
networkScope: configuredSource.networkScope,
|
networkScope: configuredSource.networkScope,
|
||||||
configuredSecrets: input.secrets.map(({ kind }) => kind),
|
configuredSecrets: input.secrets.map(({ kind }) => kind),
|
||||||
@@ -110,7 +131,7 @@ export const secretProcedures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let widgetName: string;
|
let widgetName: string;
|
||||||
let source: { id: string; name: string; auth: string | { type: string }; value: CustomWidgetSource } | undefined;
|
let source: { id: string; name: string; auth: CustomWidgetSource["auth"]; value: CustomWidgetSource } | undefined;
|
||||||
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
let target: { type: "definition"; id: string } | { type: "preview"; id: string };
|
||||||
if (input.definitionId) {
|
if (input.definitionId) {
|
||||||
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
const stored = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||||
@@ -144,7 +165,7 @@ export const secretProcedures = {
|
|||||||
target = { type: "preview", id: preview.id };
|
target = { type: "preview", id: preview.id };
|
||||||
}
|
}
|
||||||
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
if (!source) throw new TRPCError({ code: "NOT_FOUND", message: "Widget source not found" });
|
||||||
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
const kinds = [...requiredSecretKinds(getCustomWidgetSourceAuthType(source))];
|
||||||
const request = await createCustomWidgetConfigurationRequest({
|
const request = await createCustomWidgetConfigurationRequest({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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,3 +1,5 @@
|
|||||||
|
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
|
||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { parse as parseSuperJson } from "superjson";
|
import { parse as parseSuperJson } from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -24,7 +26,7 @@ export const transferProcedures = {
|
|||||||
where: eq(customWidgetDefinitions.id, input.id),
|
where: eq(customWidgetDefinitions.id, input.id),
|
||||||
});
|
});
|
||||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
return parseStoredCustomWidgetDefinition(definition);
|
return exportCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
exportLegacy: permissionRequiredProcedure
|
exportLegacy: permissionRequiredProcedure
|
||||||
@@ -57,6 +59,7 @@ export const transferProcedures = {
|
|||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
|
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
|
||||||
assertSecretSources(input.widget.sources, input.secrets);
|
assertSecretSources(input.widget.sources, input.secrets);
|
||||||
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, 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 });
|
logger.info("Imported custom widget definition", { id, name: input.widget.name });
|
||||||
@@ -80,6 +83,7 @@ export const transferProcedures = {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
|
||||||
assertSecretSources(input.widget.sources, input.secrets);
|
assertSecretSources(input.widget.sources, input.secrets);
|
||||||
const [legacy, current] = await Promise.all([
|
const [legacy, current] = await Promise.all([
|
||||||
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
|
||||||
@@ -157,7 +161,7 @@ function canPreserveLegacySecrets(
|
|||||||
widget: z.infer<typeof customWidgetImportSchema>,
|
widget: z.infer<typeof customWidgetImportSchema>,
|
||||||
) {
|
) {
|
||||||
const source = widget.sources.default;
|
const source = widget.sources.default;
|
||||||
if (!source || getAuthType(source.auth) !== legacy.authType) return false;
|
if (!source || source.type === "integration" || getAuthType(source.auth) !== legacy.authType) return false;
|
||||||
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) 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;
|
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
|
||||||
return headerName === (legacy.headerName ?? undefined);
|
return headerName === (legacy.headerName ?? undefined);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
|
||||||
|
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ async function getWorkshopWidget(submissionId: string) {
|
|||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
|
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
|
||||||
}
|
}
|
||||||
return { submission, widget: validation.data };
|
return { submission, widget: exportCustomWidgetDefinition(validation.data) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
|
throwWorkshopUnavailable("Workshop widget lookup failed", "workshop_widget_lookup_failed");
|
||||||
@@ -72,7 +74,8 @@ const workshopSearchInputSchema = z.object({
|
|||||||
const sourceOverridesSchema = z.record(
|
const sourceOverridesSchema = z.record(
|
||||||
customWidgetIdentifierSchema,
|
customWidgetIdentifierSchema,
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
baseUrl: z.string(),
|
baseUrl: z.string().optional(),
|
||||||
|
integrationId: z.string().min(1).max(100).optional(),
|
||||||
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
networkScope: z.enum(["public", "private", "loopback"]).optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -165,12 +168,14 @@ export const workshopProcedures = {
|
|||||||
sourceId,
|
sourceId,
|
||||||
{
|
{
|
||||||
baseUrl: source.baseUrl,
|
baseUrl: source.baseUrl,
|
||||||
|
integrationId: source.integrationId,
|
||||||
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
|
networkScope: source.networkScope ?? widget.sources[sourceId]?.networkScope ?? "public",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
await assertCustomWidgetIntegrationBindings(ctx, configured.sources);
|
||||||
assertSecretSources(configured.sources, input.secrets);
|
assertSecretSources(configured.sources, input.secrets);
|
||||||
const id = await insertCustomWidgetDefinition(ctx.db, configured, ctx.session.user.id, 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}`));
|
const configuredSecrets = new Set(input.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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,7 +42,8 @@ if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
|||||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||||
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
|
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
|
||||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||||
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
|
if (!jellyfinDefaultSource || jellyfinDefaultSource.type === "integration")
|
||||||
|
throw new Error("Jellyfin default source is missing");
|
||||||
|
|
||||||
function createCaller(db: ReturnType<typeof createDb>) {
|
function createCaller(db: ReturnType<typeof createDb>) {
|
||||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||||
@@ -181,7 +182,7 @@ describe("custom widget definition persistence", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultSource = previewSession.sources.default;
|
const defaultSource = previewSession.sources.default;
|
||||||
if (!defaultSource) throw new Error("Pokédex preview source is missing");
|
if (!defaultSource || defaultSource.type === "integration") throw new Error("Pokédex preview source is missing");
|
||||||
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
|
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
|
||||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||||
|
|
||||||
@@ -675,7 +676,7 @@ describe("custom widget definition persistence", () => {
|
|||||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||||
const defaultSource = definition.sources.default;
|
const defaultSource = definition.sources.default;
|
||||||
if (!defaultSource) throw new Error("Jellyfin default source is missing");
|
if (!defaultSource || defaultSource.type === "integration") throw new Error("Jellyfin default source is missing");
|
||||||
await db
|
await db
|
||||||
.update(customWidgetDefinitions)
|
.update(customWidgetDefinitions)
|
||||||
.set(
|
.set(
|
||||||
@@ -720,7 +721,7 @@ describe("custom widget definition persistence", () => {
|
|||||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
||||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||||
const expectedSource = definition.sources.default;
|
const expectedSource = definition.sources.default;
|
||||||
if (!expectedSource || typeof expectedSource.auth === "string") {
|
if (!expectedSource || expectedSource.type === "integration" || typeof expectedSource.auth === "string") {
|
||||||
throw new Error("Jellyfin default source must use header authentication");
|
throw new Error("Jellyfin default source must use header authentication");
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { parse as parseSuperJson } from "superjson";
|
import { parse as parseSuperJson } from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
normalizeCustomWidgetOptions,
|
normalizeCustomWidgetOptions,
|
||||||
validateCustomWidgetOptions,
|
validateCustomWidgetOptions,
|
||||||
} from "@homarr/custom-widgets/core";
|
} from "@homarr/custom-widgets/core";
|
||||||
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||||
@@ -121,25 +122,6 @@ const findSource = (resolved: ResolvedDefinition, sourceId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type IdentifiedRequest = CustomJsxRequest & { id: 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>(
|
const withRequestLimit = async <T>(
|
||||||
ctx: RouterContext,
|
ctx: RouterContext,
|
||||||
@@ -172,21 +154,26 @@ const executeRequest = async (
|
|||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, resolved.item.boardId), request.permission as BoardPermission);
|
||||||
const source = findSource(resolved, request.source);
|
const source = findSource(resolved, request.source);
|
||||||
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
|
const values = resolveCustomWidgetRequestValues(request, resolved.configuration, params);
|
||||||
const targetUrl = renderRequestTarget(source.baseUrl, request, values);
|
const connection = await resolveCustomWidgetSource(ctx, source, request, () =>
|
||||||
return withRequestLimit(ctx, resolved, 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, () =>
|
||||||
executeCustomWidgetRequest({
|
executeCustomWidgetRequest({
|
||||||
baseUrl: source.baseUrl,
|
...connection,
|
||||||
targetUrl,
|
targetUrl,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
body: renderRequestBody(request, values),
|
body: renderRequestBody(request, values),
|
||||||
staticHeaders: request.headers,
|
staticHeaders: request.headers,
|
||||||
auth: getAuth(resolved, source, request.auth),
|
|
||||||
networkScope: source.networkScope,
|
|
||||||
kind: request.kind,
|
kind: request.kind,
|
||||||
cacheKey: request.kind === "query" ? getCacheKey(resolved, request, values) : undefined,
|
cacheKey:
|
||||||
|
request.kind === "query" ? `${getCacheKey(resolved, request, values)}:${connection.cacheVersion}` : undefined,
|
||||||
cacheTtlSeconds: request.cacheSeconds,
|
cacheTtlSeconds: request.cacheSeconds,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const customApiRouter = createTRPCRouter({
|
export const customApiRouter = createTRPCRouter({
|
||||||
@@ -199,6 +186,7 @@ export const customApiRouter = createTRPCRouter({
|
|||||||
|
|
||||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||||
|
const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources);
|
||||||
const loadRequests = Object.entries(resolved.definition.requests).filter(
|
const loadRequests = Object.entries(resolved.definition.requests).filter(
|
||||||
([, request]) => request.kind === "query" && request.trigger === "load",
|
([, request]) => request.kind === "query" && request.trigger === "load",
|
||||||
);
|
);
|
||||||
@@ -239,7 +227,7 @@ export const customApiRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
type: "customJsx" as const,
|
type: "customJsx" as const,
|
||||||
template: resolved.definition.template,
|
template: resolved.definition.template,
|
||||||
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}`,
|
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.join(":")}`,
|
||||||
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
|
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
|
||||||
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
|
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
|
||||||
options: resolved.configuration,
|
options: resolved.configuration,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCustomWidgetSourceAuthType } from "../src/core/request-schema";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ export function getDeterministicEvaluationIssues(
|
|||||||
message: `Use the verified source URL ${expectations.sourceBaseUrl}.`,
|
message: `Use the verified source URL ${expectations.sourceBaseUrl}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
const authType = getCustomWidgetSourceAuthType(source);
|
||||||
if (authType !== expectations.sourceAuth) {
|
if (authType !== expectations.sourceAuth) {
|
||||||
issues.push({
|
issues.push({
|
||||||
path: ["sources", "default", "auth"],
|
path: ["sources", "default", "auth"],
|
||||||
@@ -467,7 +468,7 @@ const getJudgeRuntimeContext = (widget: HomarrCustomWidgetV2) => {
|
|||||||
const sections = [getCustomWidgetSkillReference("runtime").content];
|
const sections = [getCustomWidgetSkillReference("runtime").content];
|
||||||
const hasMutation = Object.values(widget.requests).some((request) => request.kind === "action");
|
const hasMutation = Object.values(widget.requests).some((request) => request.kind === "action");
|
||||||
const hasProtectedSource = Object.values(widget.sources).some((source) => {
|
const hasProtectedSource = Object.values(widget.sources).some((source) => {
|
||||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
const authType = getCustomWidgetSourceAuthType(source);
|
||||||
return authType !== "none";
|
return authType !== "none";
|
||||||
});
|
});
|
||||||
if (hasMutation || hasProtectedSource) sections.push(getCustomWidgetSkillReference("security").content);
|
if (hasMutation || hasProtectedSource) sections.push(getCustomWidgetSkillReference("security").content);
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ ${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.
|
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.
|
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.
|
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";
|
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
|
||||||
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
||||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
||||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.9.0";
|
export const CUSTOM_WIDGET_SKILL_VERSION = "2.10.0";
|
||||||
export const CUSTOM_WIDGET_SKILL_REFERENCE_NAMES = ["schema", "runtime", "security"] as const;
|
export const CUSTOM_WIDGET_SKILL_REFERENCE_NAMES = ["schema", "runtime", "security"] as const;
|
||||||
export type CustomWidgetSkillReferenceName = (typeof CUSTOM_WIDGET_SKILL_REFERENCE_NAMES)[number];
|
export type CustomWidgetSkillReferenceName = (typeof CUSTOM_WIDGET_SKILL_REFERENCE_NAMES)[number];
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ interface HomarrCustomWidgetV2 {
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
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?\`:
|
\`sources.default\` is required. HTTP source properties: \`name?\`, \`baseUrl\`, \`networkScope\`, and \`auth?\`:
|
||||||
|
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
@@ -85,14 +85,17 @@ The object key \`default\` is the required source ID; \`default\` is not a prope
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
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\`.
|
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.
|
||||||
|
|
||||||
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.
|
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\`.
|
||||||
|
|
||||||
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.
|
HTTP sources declare public URLs or self-hosted suggestions; installers configure their URL, network scope, and credentials separately.
|
||||||
|
|
||||||
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
|
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\`.
|
||||||
`,
|
`,
|
||||||
|
|
||||||
"references/runtime.md": `# Runtime
|
"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\`.
|
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,6 +104,17 @@ type Definition = z.infer<typeof customWidgetDefinitionSchema>;
|
|||||||
|
|
||||||
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
|
function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
|
||||||
for (const [requestId, request] of Object.entries(definition.requests)) {
|
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]) {
|
if (!definition.sources[request.source]) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCustomWidgetSourceAuthType } from "./request-schema";
|
||||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||||
import { customWidgetImportSchema } from "./custom-jsx-schema";
|
import { customWidgetImportSchema } from "./custom-jsx-schema";
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetPa
|
|||||||
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
|
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
|
||||||
const parsed = customWidgetImportSchema.safeParse(widget);
|
const parsed = customWidgetImportSchema.safeParse(widget);
|
||||||
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
|
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
|
||||||
return { success: true, widget: parsed.data, warnings: [] };
|
return { success: true, widget: exportCustomWidgetDefinition(parsed.data), warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
|
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
|
||||||
@@ -157,17 +158,35 @@ export function getImportReview(value: unknown): ImportReview | null {
|
|||||||
const widget = parsed.data;
|
const widget = parsed.data;
|
||||||
return {
|
return {
|
||||||
name: widget.name,
|
name: widget.name,
|
||||||
origins: Object.values(widget.sources).map((source) => new URL(source.baseUrl).origin),
|
origins: Object.values(widget.sources).map((source) =>
|
||||||
|
source.type === "integration" ? source.integrationKind : new URL(source.baseUrl).origin,
|
||||||
|
),
|
||||||
authTypes: [
|
authTypes: [
|
||||||
...new Set(
|
...new Set(
|
||||||
Object.values(widget.sources).map((source) =>
|
Object.values(widget.sources).map((source) =>
|
||||||
typeof source.auth === "string" ? source.auth : source.auth.type,
|
source.type === "integration" ? "integration" : getCustomWidgetSourceAuthType(source),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
networkScopes: [...new Set(Object.values(widget.sources).map((source) => source.networkScope))],
|
networkScopes: [
|
||||||
|
...new Set(Object.values(widget.sources).flatMap((source) => (source.networkScope ? [source.networkScope] : []))),
|
||||||
|
],
|
||||||
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
|
methods: [...new Set(Object.values(widget.requests).map((request) => request.method))],
|
||||||
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
|
permissions: [...new Set(Object.values(widget.requests).map((request) => request.permission))],
|
||||||
hasActions: Object.values(widget.requests).some((request) => request.kind === "action"),
|
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,7 +41,10 @@ const sourceUrlIssueMessages: Record<CustomWidgetSourceUrlIssue, string> = {
|
|||||||
queryOrFragment: "API source URLs cannot contain a query string or fragment",
|
queryOrFragment: "API source URLs cannot contain a query string or fragment",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const customWidgetSourceSchema = z.strictObject({
|
export const customWidgetHttpSourceSchema = z.strictObject({
|
||||||
|
type: z.literal("http").optional(),
|
||||||
|
integrationKind: z.never().optional(),
|
||||||
|
integrationId: z.never().optional(),
|
||||||
name: z.string().trim().min(1).max(128).optional(),
|
name: z.string().trim().min(1).max(128).optional(),
|
||||||
baseUrl: z
|
baseUrl: z
|
||||||
.string()
|
.string()
|
||||||
@@ -54,7 +57,25 @@ export const customWidgetSourceSchema = z.strictObject({
|
|||||||
networkScope: z.enum(customJsxNetworkScopes),
|
networkScope: z.enum(customJsxNetworkScopes),
|
||||||
auth: authSchema.default("none"),
|
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 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
|
export const customWidgetSourcesSchema = z
|
||||||
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
|
.record(customWidgetIdentifierSchema, customWidgetSourceSchema)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getCustomWidgetSourceAuthType } from "./request-schema";
|
||||||
import type { CustomWidgetSource } from "./request-schema";
|
import type { CustomWidgetSource } from "./request-schema";
|
||||||
import type { CustomWidgetSecretKind } from "./schema-types";
|
import type { CustomWidgetSecretKind } from "./schema-types";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export function getCustomWidgetSecretRequirements(
|
|||||||
sources: Record<string, CustomWidgetSource>,
|
sources: Record<string, CustomWidgetSource>,
|
||||||
): CustomWidgetSecretRequirement[] {
|
): CustomWidgetSecretRequirement[] {
|
||||||
return Object.entries(sources).flatMap(([sourceId, source]) => {
|
return Object.entries(sources).flatMap(([sourceId, source]) => {
|
||||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
const authType = getCustomWidgetSourceAuthType(source);
|
||||||
return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({
|
return getCustomWidgetRequiredSecretKinds(authType).map((kind) => ({
|
||||||
sourceId,
|
sourceId,
|
||||||
sourceName: source.name ?? sourceId,
|
sourceName: source.name ?? sourceId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CustomWidgetSource } from "./request-schema";
|
import { getCustomWidgetSourceAuthType } from "./request-schema";
|
||||||
|
import type { CustomJsxNetworkScope, CustomWidgetSource } from "./request-schema";
|
||||||
import { getCustomWidgetSourceUrlIssue } from "./request-schema";
|
import { getCustomWidgetSourceUrlIssue } from "./request-schema";
|
||||||
import type { CustomWidgetSecretKind } from "./schema-types";
|
import type { CustomWidgetSecretKind } from "./schema-types";
|
||||||
import { getCustomWidgetSecretRequirements } from "./secret-requirements";
|
import { getCustomWidgetSecretRequirements } from "./secret-requirements";
|
||||||
@@ -15,7 +16,9 @@ export interface CustomWidgetSourceSetup {
|
|||||||
sourceName: string;
|
sourceName: string;
|
||||||
suggestedBaseUrl: string;
|
suggestedBaseUrl: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
networkScope: CustomWidgetSource["networkScope"];
|
networkScope: CustomJsxNetworkScope;
|
||||||
|
integrationKind?: string;
|
||||||
|
integrationId?: string;
|
||||||
authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
|
authType: "none" | "bearer" | "basic" | "apiKeyHeader" | "apiKeyQuery";
|
||||||
credentialFields: Array<{
|
credentialFields: Array<{
|
||||||
kind: CustomWidgetSecretKind;
|
kind: CustomWidgetSecretKind;
|
||||||
@@ -40,10 +43,18 @@ export function isCustomWidgetSourceUrlPlaceholder(baseUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) {
|
export function customWidgetSourceRequiresUrlConfirmation(source: CustomWidgetSource) {
|
||||||
|
if (source.type === "integration") return !source.integrationId;
|
||||||
return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl);
|
return source.networkScope !== "public" || isCustomWidgetSourceUrlPlaceholder(source.baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasSameCustomWidgetSourceAuthentication(left: CustomWidgetSource, right: CustomWidgetSource) {
|
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;
|
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;
|
return left.auth.type === right.auth.type && left.auth.name === right.auth.name;
|
||||||
}
|
}
|
||||||
@@ -54,13 +65,15 @@ export function getCustomWidgetSourceSetups(
|
|||||||
): CustomWidgetSourceSetup[] {
|
): CustomWidgetSourceSetup[] {
|
||||||
const requirements = getCustomWidgetSecretRequirements(sources);
|
const requirements = getCustomWidgetSecretRequirements(sources);
|
||||||
return Object.entries(sources).map(([sourceId, source]) => {
|
return Object.entries(sources).map(([sourceId, source]) => {
|
||||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
const authType = getCustomWidgetSourceAuthType(source);
|
||||||
return {
|
return {
|
||||||
sourceId,
|
sourceId,
|
||||||
sourceName: source.name ?? sourceId,
|
sourceName: source.name ?? sourceId,
|
||||||
suggestedBaseUrl: source.baseUrl,
|
suggestedBaseUrl: source.baseUrl ?? "",
|
||||||
baseUrl: source.baseUrl,
|
baseUrl: source.baseUrl ?? "",
|
||||||
networkScope: source.networkScope,
|
networkScope: source.networkScope ?? "private",
|
||||||
|
integrationKind: source.integrationKind,
|
||||||
|
integrationId: source.integrationId,
|
||||||
authType,
|
authType,
|
||||||
credentialFields: requirements
|
credentialFields: requirements
|
||||||
.filter((requirement) => requirement.sourceId === sourceId)
|
.filter((requirement) => requirement.sourceId === sourceId)
|
||||||
@@ -81,12 +94,23 @@ export function getCustomWidgetSourceSetups(
|
|||||||
|
|
||||||
export function applyCustomWidgetSourceSetup(
|
export function applyCustomWidgetSourceSetup(
|
||||||
sources: Record<string, CustomWidgetSource>,
|
sources: Record<string, CustomWidgetSource>,
|
||||||
setup: Record<string, Pick<CustomWidgetSource, "baseUrl" | "networkScope">>,
|
setup: Record<string, { baseUrl?: string; networkScope?: CustomJsxNetworkScope; integrationId?: string }>,
|
||||||
) {
|
) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(sources).map(([sourceId, source]) => {
|
Object.entries(sources).map(([sourceId, source]) => {
|
||||||
const configured = setup[sourceId];
|
const configured = setup[sourceId];
|
||||||
return [sourceId, configured ? { ...source, ...configured } : source];
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||||
|
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
|
||||||
|
|
||||||
type HeaderSetter = Pick<Headers, "set">;
|
type HeaderSetter = Pick<Headers, "set">;
|
||||||
|
|
||||||
export const AUTH_HANDLERS: Record<
|
export const AUTH_HANDLERS: Record<
|
||||||
@@ -43,3 +46,54 @@ export function applyAuth(
|
|||||||
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
|
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,3 +39,7 @@ export {
|
|||||||
resolveCustomWidgetRequestValues,
|
resolveCustomWidgetRequestValues,
|
||||||
} from "./request-manifest";
|
} from "./request-manifest";
|
||||||
export type { CustomJsxRuntimeParams } from "./request-manifest";
|
export type { CustomJsxRuntimeParams } from "./request-manifest";
|
||||||
|
|
||||||
|
export { redactResponseSecrets } from "./response";
|
||||||
|
|
||||||
|
export { assertCustomWidgetPathScope } from "./network-policy";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lookup } from "node:dns/promises";
|
import { lookup } from "node:dns/promises";
|
||||||
|
import type { ConnectionOptions } from "node:tls";
|
||||||
import { BlockList, isIP } from "node:net";
|
import { BlockList, isIP } from "node:net";
|
||||||
import type { LookupFunction } from "node:net";
|
import type { LookupFunction } from "node:net";
|
||||||
import { Agent } from "undici";
|
import { Agent } from "undici";
|
||||||
@@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
export function createPinnedAgent(
|
||||||
|
addresses: ResolvedAddress[],
|
||||||
|
timeoutMs: number,
|
||||||
|
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
|
||||||
|
) {
|
||||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||||
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||||
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
||||||
@@ -204,10 +209,42 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe
|
|||||||
else callback(null, selected.address, selected.family);
|
else callback(null, selected.address, selected.family);
|
||||||
};
|
};
|
||||||
return new Agent({
|
return new Agent({
|
||||||
connect: { lookup: customLookup },
|
connect: { ...tls, lookup: customLookup },
|
||||||
connectTimeout: timeoutMs,
|
connectTimeout: timeoutMs,
|
||||||
headersTimeout: timeoutMs,
|
headersTimeout: timeoutMs,
|
||||||
bodyTimeout: timeoutMs,
|
bodyTimeout: timeoutMs,
|
||||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
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,3 +1,4 @@
|
|||||||
|
import { hasSameCustomWidgetSourceAuthentication } from "../core";
|
||||||
import { customWidgetDefinitionSchema } from "../core";
|
import { customWidgetDefinitionSchema } from "../core";
|
||||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||||
import { CustomWidgetDomainError } from "./errors";
|
import { CustomWidgetDomainError } from "./errors";
|
||||||
@@ -36,3 +37,9 @@ export function validatePreviewTemplateRevision(current: PreviewTemplateContext,
|
|||||||
}
|
}
|
||||||
return parsed.data.template;
|
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,3 +1,4 @@
|
|||||||
|
import { canConfigurePreviewSource } from "./preview-session-template";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -5,7 +6,6 @@ import {
|
|||||||
customWidgetRequestsSchema,
|
customWidgetRequestsSchema,
|
||||||
customWidgetSecretKinds,
|
customWidgetSecretKinds,
|
||||||
customWidgetSourcesSchema,
|
customWidgetSourcesSchema,
|
||||||
hasSameCustomWidgetSourceAuthentication,
|
|
||||||
} from "../core";
|
} from "../core";
|
||||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||||
import { CustomWidgetDomainError } from "./errors";
|
import { CustomWidgetDomainError } from "./errors";
|
||||||
@@ -242,7 +242,7 @@ export class CustomWidgetPreviewSessionService {
|
|||||||
if (!currentSource) {
|
if (!currentSource) {
|
||||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||||
}
|
}
|
||||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
|
if (!canConfigurePreviewSource(currentSource, source)) {
|
||||||
throw new CustomWidgetDomainError({
|
throw new CustomWidgetDomainError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Preview source authentication changed; create a new configuration request",
|
message: "Preview source authentication changed; create a new configuration request",
|
||||||
@@ -253,7 +253,7 @@ export class CustomWidgetPreviewSessionService {
|
|||||||
...session,
|
...session,
|
||||||
sources: {
|
sources: {
|
||||||
...session.sources,
|
...session.sources,
|
||||||
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
|
[sourceId]: source,
|
||||||
},
|
},
|
||||||
secrets: [
|
secrets: [
|
||||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Buffer } from "node:buffer";
|
|||||||
import { STATUS_CODES } from "node:http";
|
import { STATUS_CODES } from "node:http";
|
||||||
import { Headers, Response } from "undici";
|
import { Headers, Response } from "undici";
|
||||||
|
|
||||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
|
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||||
import { applyAuth } from "./auth";
|
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||||
|
import { applyAuth, performAuthenticatedRequest } from "./auth";
|
||||||
import { CustomWidgetDomainError } from "./errors";
|
import { CustomWidgetDomainError } from "./errors";
|
||||||
import {
|
import {
|
||||||
assertSafeStaticHeaders,
|
assertSafeStaticHeaders,
|
||||||
|
assertCustomWidgetPathScope,
|
||||||
createPinnedAgent,
|
createPinnedAgent,
|
||||||
resolveAndValidateHost,
|
resolveAndValidateHost,
|
||||||
resolveSameOriginTarget,
|
resolveSameOriginTarget,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
} from "./network-policy";
|
} from "./network-policy";
|
||||||
import { closeDispatcher } from "./request-dispatcher-lifecycle";
|
import { closeDispatcher } from "./request-dispatcher-lifecycle";
|
||||||
import { parseResponseBody } from "./response";
|
import { parseResponseBody } from "./response";
|
||||||
|
import { redactResponseSecrets } from "./response";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertSafeStaticHeaders,
|
assertSafeStaticHeaders,
|
||||||
@@ -37,32 +40,6 @@ 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 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";
|
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 cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||||
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||||
let cacheEpoch = 0;
|
let cacheEpoch = 0;
|
||||||
@@ -75,7 +52,8 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
|
||||||
try {
|
try {
|
||||||
return await performRequestWithinDeadline(input, controller.signal);
|
assertRequest(input);
|
||||||
|
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
throw new CustomWidgetDomainError({
|
throw new CustomWidgetDomainError({
|
||||||
@@ -107,12 +85,17 @@ async function performRequestWithinDeadline(
|
|||||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||||
let currentMethod = input.method;
|
let currentMethod = input.method;
|
||||||
let currentBody = input.body;
|
let currentBody = input.auth?.transformBody ? input.auth.transformBody(input.body) : input.body;
|
||||||
|
assertRequest({ ...input, body: currentBody });
|
||||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||||
for (let redirects = 0; ; redirects += 1) {
|
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(
|
const dispatcher = createPinnedAgent(
|
||||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
|
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
|
input.tls,
|
||||||
);
|
);
|
||||||
const headers = buildHeaders(input, currentUrl, currentBody);
|
const headers = buildHeaders(input, currentUrl, currentBody);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -135,15 +118,22 @@ async function performRequestWithinDeadline(
|
|||||||
statusText: STATUS_CODES[responseData.statusCode] ?? "",
|
statusText: STATUS_CODES[responseData.statusCode] ?? "",
|
||||||
headers: normalizeResponseHeaders(responseData.headers),
|
headers: normalizeResponseHeaders(responseData.headers),
|
||||||
});
|
});
|
||||||
result = {
|
const parsed = {
|
||||||
kind: "response",
|
ok: response.ok,
|
||||||
response: {
|
status: response.status,
|
||||||
ok: response.ok,
|
statusText: response.statusText,
|
||||||
status: response.status,
|
data: await parseResponseBody(response),
|
||||||
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 {
|
} else {
|
||||||
await responseData.body.dump();
|
await responseData.body.dump();
|
||||||
result = {
|
result = {
|
||||||
@@ -248,6 +238,8 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | u
|
|||||||
if (input.auth) {
|
if (input.auth) {
|
||||||
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||||
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
|
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;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
import type { Response } from "undici";
|
import type { Response } from "undici";
|
||||||
import { CustomWidgetDomainError } from "./errors";
|
import { CustomWidgetDomainError } from "./errors";
|
||||||
|
|
||||||
@@ -63,3 +64,39 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
|
|||||||
|
|
||||||
const tooLarge = () =>
|
const tooLarge = () =>
|
||||||
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
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", () => {
|
describe("authoring resources", () => {
|
||||||
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
|
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
|
||||||
const skill = getCustomWidgetSkill();
|
const skill = getCustomWidgetSkill();
|
||||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.9.0" });
|
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.10.0" });
|
||||||
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
|
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
|
||||||
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
|
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
|
||||||
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
|
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react";
|
import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CustomWidgetSecretKind,
|
CustomWidgetSecretKind,
|
||||||
CustomWidgetSource,
|
CustomJsxNetworkScope,
|
||||||
CustomWidgetSourceSetup,
|
CustomWidgetSourceSetup,
|
||||||
CustomWidgetSourceUrlIssue,
|
CustomWidgetSourceUrlIssue,
|
||||||
} from "../core";
|
} from "../core";
|
||||||
@@ -11,7 +12,8 @@ import { getCustomWidgetSourceSetupIssue } from "../core";
|
|||||||
|
|
||||||
export interface CustomWidgetSourceSetupValue {
|
export interface CustomWidgetSourceSetupValue {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
networkScope: CustomWidgetSource["networkScope"];
|
networkScope: CustomJsxNetworkScope;
|
||||||
|
integrationId?: string;
|
||||||
urlConfirmed: boolean;
|
urlConfirmed: boolean;
|
||||||
secrets: Partial<Record<CustomWidgetSecretKind, string>>;
|
secrets: Partial<Record<CustomWidgetSecretKind, string>>;
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,7 @@ export interface CustomWidgetSourceSetupPanelProps {
|
|||||||
values: Record<string, CustomWidgetSourceSetupValue>;
|
values: Record<string, CustomWidgetSourceSetupValue>;
|
||||||
messages: CustomWidgetSourceSetupMessages;
|
messages: CustomWidgetSourceSetupMessages;
|
||||||
onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void;
|
onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void;
|
||||||
|
renderIntegrationSource?(setup: CustomWidgetSourceSetup, value: CustomWidgetSourceSetupValue): ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCustomWidgetSourceSetupValues(
|
export function createCustomWidgetSourceSetupValues(
|
||||||
@@ -48,6 +51,7 @@ export function createCustomWidgetSourceSetupValues(
|
|||||||
setup.sourceId,
|
setup.sourceId,
|
||||||
{
|
{
|
||||||
baseUrl: setup.baseUrl,
|
baseUrl: setup.baseUrl,
|
||||||
|
integrationId: setup.integrationId,
|
||||||
networkScope: setup.networkScope,
|
networkScope: setup.networkScope,
|
||||||
urlConfirmed: !setup.requiresUrlConfirmation,
|
urlConfirmed: !setup.requiresUrlConfirmation,
|
||||||
secrets: {} as Partial<Record<CustomWidgetSecretKind, string>>,
|
secrets: {} as Partial<Record<CustomWidgetSecretKind, string>>,
|
||||||
@@ -62,6 +66,7 @@ export function isCustomWidgetSourceSetupReady(
|
|||||||
) {
|
) {
|
||||||
return setups.every((setup) => {
|
return setups.every((setup) => {
|
||||||
const value = values[setup.sourceId];
|
const value = values[setup.sourceId];
|
||||||
|
if (setup.integrationKind) return Boolean(value?.integrationId);
|
||||||
return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed);
|
return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,7 @@ export function CustomWidgetSourceSetupPanel({
|
|||||||
values,
|
values,
|
||||||
messages,
|
messages,
|
||||||
onChange,
|
onChange,
|
||||||
|
renderIntegrationSource,
|
||||||
}: CustomWidgetSourceSetupPanelProps) {
|
}: CustomWidgetSourceSetupPanelProps) {
|
||||||
if (setups.length === 0) return null;
|
if (setups.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +90,14 @@ export function CustomWidgetSourceSetupPanel({
|
|||||||
{setups.map((setup) => {
|
{setups.map((setup) => {
|
||||||
const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId];
|
const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId];
|
||||||
if (!value) return null;
|
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 issue = getCustomWidgetSourceSetupIssue(value);
|
||||||
const missingCredentials = setup.credentialFields.filter(
|
const missingCredentials = setup.credentialFields.filter(
|
||||||
(field) => !field.configured && !value.secrets[field.kind]?.trim(),
|
(field) => !field.configured && !value.secrets[field.kind]?.trim(),
|
||||||
@@ -143,7 +157,7 @@ export function CustomWidgetSourceSetupPanel({
|
|||||||
networkScope &&
|
networkScope &&
|
||||||
onChange(setup.sourceId, {
|
onChange(setup.sourceId, {
|
||||||
...value,
|
...value,
|
||||||
networkScope: networkScope as CustomWidgetSource["networkScope"],
|
networkScope: networkScope as CustomJsxNetworkScope,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -725,3 +725,8 @@ export const integrationCategories = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
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,6 +1,7 @@
|
|||||||
import { ParseError } from "@homarr/common/server";
|
import { ParseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -10,6 +11,10 @@ import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-sum
|
|||||||
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
|
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
|
||||||
|
|
||||||
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}` } };
|
||||||
|
}
|
||||||
|
|
||||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||||
const headers = {
|
const headers = {
|
||||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ResponseError } from "@homarr/common/server";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
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 type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -14,11 +16,13 @@ import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpd
|
|||||||
const logger = createLogger({ module: "anchorIntegration" });
|
const logger = createLogger({ module: "anchorIntegration" });
|
||||||
|
|
||||||
export class AnchorIntegration extends Integration {
|
export class AnchorIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return bearerAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
|
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -36,6 +37,10 @@ interface WarriorSnapshotState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ArchiveTeamWarriorIntegration extends Integration {
|
export class ArchiveTeamWarriorIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.createAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/index.html"), {
|
const response = await input.fetchAsync(this.url("/index.html"), {
|
||||||
headers: this.createAuthHeaders(),
|
headers: this.createAuthHeaders(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -26,6 +27,10 @@ const mediaTypeCountField = {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export class AudiobookshelfIntegration extends Integration {
|
export class AudiobookshelfIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders(), redactValues: [] };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
|
const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { isHttpIntegrationKind } from "@homarr/definitions";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
|
|
||||||
|
import { clientAuthentication } from "../http-auth";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
const logger = createLogger({ module: "integrationFactory" });
|
const logger = createLogger({ module: "integrationFactory" });
|
||||||
@@ -162,3 +164,8 @@ export async function createIntegrationAsync(
|
|||||||
throw error;
|
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,6 +7,7 @@ import { removeTrailingSlash } from "@homarr/common";
|
|||||||
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { HandleIntegrationErrors } from "./errors/decorator";
|
import { HandleIntegrationErrors } from "./errors/decorator";
|
||||||
import { TestConnectionError } from "./test-connection/test-connection-error";
|
import { TestConnectionError } from "./test-connection/test-connection-error";
|
||||||
import type { TestingResult } from "./test-connection/test-connection-service";
|
import type { TestingResult } from "./test-connection/test-connection-service";
|
||||||
@@ -36,6 +37,8 @@ export interface IntegrationTestingInput {
|
|||||||
export abstract class Integration {
|
export abstract class Integration {
|
||||||
constructor(protected integration: IntegrationInput) {}
|
constructor(protected integration: IntegrationInput) {}
|
||||||
|
|
||||||
|
public abstract getHttpAuthenticationAsync(refresh?: boolean): Promise<IntegrationHttpAuthentication>;
|
||||||
|
|
||||||
public get publicIntegration() {
|
public get publicIntegration() {
|
||||||
return {
|
return {
|
||||||
id: this.integration.id,
|
id: this.integration.id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -8,6 +9,10 @@ import type { BazarrBadges } from "./bazarr-types";
|
|||||||
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
|
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
|
||||||
|
|
||||||
export class BazarrIntegration extends Integration {
|
export class BazarrIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders(), redactValues: [] };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/system/status"), {
|
const response = await input.fetchAsync(this.url("/api/system/status"), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
|
|||||||
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
|
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
|
||||||
|
|
||||||
export class BeszelIntegration extends Integration {
|
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>;
|
private readonly sessionStore: SessionStore<BeszelSession>;
|
||||||
|
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
@@ -32,6 +33,10 @@ import {
|
|||||||
} from "./coolify-types";
|
} from "./coolify-types";
|
||||||
|
|
||||||
export class CoolifyIntegration extends Integration {
|
export class CoolifyIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const healthUrl = this.url("/api/health");
|
const healthUrl = this.url("/api/health");
|
||||||
const healthResponse = await input.fetchAsync(healthUrl, {});
|
const healthResponse = await input.fetchAsync(healthUrl, {});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -14,6 +15,10 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
|
|||||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||||
|
|
||||||
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/info"));
|
const response = await input.fetchAsync(this.url("/info"));
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { fetch as undiciFetch } from "undici";
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||||
@@ -13,6 +14,27 @@ import type { DownloadClientItem } from "../../interfaces/downloads/download-cli
|
|||||||
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
|
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
|
||||||
|
|
||||||
export class Aria2Integration extends Integration implements IDownloadClientIntegration {
|
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> {
|
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||||
const client = this.getClient();
|
const client = this.getClient();
|
||||||
const keys: (keyof Aria2Download)[] = [
|
const keys: (keyof Aria2Download)[] = [
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
|||||||
|
|
||||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||||
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
|
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> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const client = await this.getClientAsync(input.dispatcher);
|
const client = await this.getClientAsync(input.dispatcher);
|
||||||
const isSuccess = await client.login();
|
const isSuccess = await client.login();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { fetch as undiciFetch } from "undici";
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
|
import { basicAuth } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||||
@@ -14,6 +16,10 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
|||||||
import type { NzbGetClient } from "./nzbget-types";
|
import type { NzbGetClient } from "./nzbget-types";
|
||||||
|
|
||||||
export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
|
export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return basicAuth()(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
|
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
import { QBittorrent } from "@ctrl/qbittorrent";
|
import { QBittorrent } from "@ctrl/qbittorrent";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import type { Dispatcher } from "undici";
|
import type { Dispatcher } from "undici";
|
||||||
@@ -17,6 +18,17 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
|||||||
|
|
||||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||||
export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
|
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> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const client = await this.getClientAsync(input.dispatcher);
|
const client = await this.getClientAsync(input.dispatcher);
|
||||||
const isSuccess = await client.login();
|
const isSuccess = await client.login();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { fetch as undiciFetch } from "undici";
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
|
import { queryAuth } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||||
@@ -17,6 +19,10 @@ import { historySchema, queueSchema } from "./sabnzbd-schema";
|
|||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
|
export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return queryAuth("apikey")(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
//This is the one call that uses the least amount of data while requiring the api key
|
//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" });
|
await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" });
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
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 type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||||
@@ -49,6 +51,10 @@ const logger = createLogger({ module: "slskd-integration" });
|
|||||||
* Slskd integration cannot pause or stop downloads, it can only list downloads.
|
* Slskd integration cannot pause or stop downloads, it can only list downloads.
|
||||||
*/
|
*/
|
||||||
export class SlskdIntegration extends Integration implements IDownloadClientIntegration {
|
export class SlskdIntegration extends Integration implements IDownloadClientIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
public pauseQueueAsync(): Promise<void> {
|
public pauseQueueAsync(): Promise<void> {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -76,9 +82,7 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte
|
|||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/v0/server"), {
|
const response = await input.fetchAsync(this.url("/api/v0/server"), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-API-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -148,9 +152,7 @@ export class SlskdIntegration extends Integration implements IDownloadClientInte
|
|||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-API-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
|
|||||||
|
|
||||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||||
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
|
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> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const client = await this.getClientAsync(input.dispatcher);
|
const client = await this.getClientAsync(input.dispatcher);
|
||||||
await client.getSession();
|
await client.getSession();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { z } from "zod/v4";
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
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 type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -90,6 +92,10 @@ const userSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
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 apiKeyHeader = "X-Emby-Token";
|
||||||
private static readonly deviceId = "homarr-emby-integration";
|
private static readonly deviceId = "homarr-emby-integration";
|
||||||
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { createIntegrationAsync } from "./base/creator";
|
export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator";
|
||||||
|
|
||||||
|
export type { IntegrationHttpAuthentication } from "./http-auth";
|
||||||
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { SessionStore } from "../base/session-store";
|
import type { SessionStore } from "../base/session-store";
|
||||||
@@ -14,6 +15,10 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
|
|||||||
import type { SystemHealthMonitoring } from "../types";
|
import type { SystemHealthMonitoring } from "../types";
|
||||||
|
|
||||||
export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
private readonly sessionStore: SessionStore<{ version: string }>;
|
private readonly sessionStore: SessionStore<{ version: string }>;
|
||||||
|
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -16,6 +17,10 @@ import {
|
|||||||
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
||||||
|
|
||||||
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const authHeaders = this.getAuthHeaders();
|
const authHeaders = this.getAuthHeaders();
|
||||||
const url = this.url("/v1/vpn/status");
|
const url = this.url("/v1/vpn/status");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ResponseError } from "@homarr/common/server";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { ImageProxy } from "@homarr/image-proxy";
|
import { ImageProxy } from "@homarr/image-proxy";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
@@ -10,6 +11,10 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti
|
|||||||
import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema";
|
import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema";
|
||||||
|
|
||||||
export class GotifyIntegration extends Integration implements INotificationsIntegration {
|
export class GotifyIntegration extends Integration implements INotificationsIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
await input.fetchAsync(this.url("/health"), { headers: this.getHeaders() });
|
await input.fetchAsync(this.url("/health"), { headers: this.getHeaders() });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -17,6 +18,10 @@ import { calendarEventSchema, calendarsSchema, entityStateSchema } from "./homea
|
|||||||
const logger = createLogger({ module: "homeAssistantIntegration" });
|
const logger = createLogger({ module: "homeAssistantIntegration" });
|
||||||
|
|
||||||
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration {
|
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
public async getEntityStateAsync(entityId: string) {
|
public async getEntityStateAsync(entityId: string) {
|
||||||
try {
|
try {
|
||||||
const response = await this.getAsync(`/api/states/${entityId}`);
|
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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,6 +2,7 @@ import ICAL from "ical.js";
|
|||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -10,6 +11,20 @@ import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integ
|
|||||||
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
|
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
|
||||||
|
|
||||||
export class ICalIntegration extends Integration implements ICalendarIntegration {
|
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[]> {
|
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
|
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
|
||||||
const result = await response.text();
|
const result = await response.text();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ImageProxy } from "@homarr/image-proxy";
|
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 type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
@@ -48,6 +50,10 @@ const logger = createLogger({ module: "immich-integration" });
|
|||||||
const carouselPhotoCount = 50;
|
const carouselPhotoCount = 50;
|
||||||
|
|
||||||
export class ImmichIntegration extends Integration {
|
export class ImmichIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
super(integration);
|
super(integration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ export function parseLocation(remoteEndPoint: string | null | undefined): "lan"
|
|||||||
|
|
||||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
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({
|
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
name: "Homarr",
|
name: "Homarr",
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
|
import { apiKeyAuth } from "../http-auth";
|
||||||
import type { MediaAvailability } from "../interfaces/media-requests/media-request-types";
|
import type { MediaAvailability } from "../interfaces/media-requests/media-request-types";
|
||||||
import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types";
|
import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
|
|
||||||
export class JellyseerrIntegration extends OverseerrIntegration {
|
export class JellyseerrIntegration extends OverseerrIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected override mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability {
|
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))
|
// 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";
|
if (availability === UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted) return "blacklisted";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -22,6 +23,10 @@ import {
|
|||||||
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
|
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export class LlamacppIntegration extends Integration {
|
export class LlamacppIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/health"), {
|
const response = await input.fetchAsync(this.url("/health"), {
|
||||||
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
|
import { apiKeyAuth } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||||
@@ -14,9 +16,13 @@ import { mediaOrganizerPriorities } from "../media-organizer";
|
|||||||
const logger = createLogger({ module: "lidarrIntegration" });
|
const logger = createLogger({ module: "lidarrIntegration" });
|
||||||
|
|
||||||
export class LidarrIntegration extends Integration implements ICalendarIntegration {
|
export class LidarrIntegration extends Integration implements ICalendarIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api"), {
|
const response = await input.fetchAsync(this.url("/api"), {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
@@ -39,9 +45,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
|
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
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 type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||||
@@ -17,6 +19,10 @@ import { mediaOrganizerPriorities } from "../media-organizer";
|
|||||||
const logger = createLogger({ module: "radarrIntegration" });
|
const logger = createLogger({ module: "radarrIntegration" });
|
||||||
|
|
||||||
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
|
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.
|
* Gets the events in the Radarr calendar between two dates.
|
||||||
* @param start The start date
|
* @param start The start date
|
||||||
@@ -31,9 +37,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
||||||
|
|
||||||
@@ -122,7 +126,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
const data = await z
|
const data = await z
|
||||||
.object({
|
.object({
|
||||||
@@ -154,7 +158,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
const data = await z
|
const data = await z
|
||||||
.object({
|
.object({
|
||||||
@@ -188,7 +192,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api"), {
|
const response = await input.fetchAsync(this.url("/api"), {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
|
import { apiKeyAuth } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||||
@@ -14,9 +16,13 @@ import { mediaOrganizerPriorities } from "../media-organizer";
|
|||||||
const logger = createLogger({ module: "readarrIntegration" });
|
const logger = createLogger({ module: "readarrIntegration" });
|
||||||
|
|
||||||
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
|
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api"), {
|
const response = await input.fetchAsync(this.url("/api"), {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
@@ -45,9 +51,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
|
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../../http-auth";
|
||||||
|
import { apiKeyAuth } from "../../http-auth";
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../../base/integration";
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||||
@@ -16,6 +18,10 @@ import { mediaOrganizerPriorities } from "../media-organizer";
|
|||||||
const logger = createLogger({ module: "sonarrIntegration" });
|
const logger = createLogger({ module: "sonarrIntegration" });
|
||||||
|
|
||||||
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
|
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.
|
* Gets the events in the Sonarr calendar between two dates.
|
||||||
* @param start The start date
|
* @param start The start date
|
||||||
@@ -33,9 +39,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
|
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
const data = await z
|
const data = await z
|
||||||
.object({
|
.object({
|
||||||
@@ -157,7 +161,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
const data = await z
|
const data = await z
|
||||||
.object({
|
.object({
|
||||||
@@ -193,7 +197,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api"), {
|
const response = await input.fetchAsync(this.url("/api"), {
|
||||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
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 type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -9,12 +11,17 @@ import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/med
|
|||||||
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
|
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
|
||||||
|
|
||||||
export class TdarrIntegration extends Integration implements IMediaTranscodingIntegration {
|
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> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
|
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
|
...(await this.getHttpAuthenticationAsync()).headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,7 +37,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
|
|||||||
const headerParams = {
|
const headerParams = {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
...(await this.getHttpAuthenticationAsync()).headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
@@ -76,7 +83,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
|
|||||||
const url = this.url("/api/v2/get-nodes");
|
const url = this.url("/api/v2/get-nodes");
|
||||||
const headerParams = {
|
const headerParams = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
...(await this.getHttpAuthenticationAsync()).headers,
|
||||||
};
|
};
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -120,7 +127,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
|
|||||||
const url = this.url("/api/v2/client/status-tables");
|
const url = this.url("/api/v2/client/status-tables");
|
||||||
const headerParams = {
|
const headerParams = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
...(await this.getHttpAuthenticationAsync()).headers,
|
||||||
};
|
};
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -159,7 +166,7 @@ export class TdarrIntegration extends Integration implements IMediaTranscodingIn
|
|||||||
const url = this.url("/api/v2/client/status-tables");
|
const url = this.url("/api/v2/client/status-tables");
|
||||||
const headerParams = {
|
const headerParams = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
...(await this.getHttpAuthenticationAsync()).headers,
|
||||||
};
|
};
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
@@ -52,6 +53,10 @@ export class MockIntegration
|
|||||||
ISmartHomeIntegration,
|
ISmartHomeIntegration,
|
||||||
IUpsSummaryIntegration
|
IUpsSummaryIntegration
|
||||||
{
|
{
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
throw new Error("This integration does not use HTTP");
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly dnsHole = new DnsHoleMockService();
|
private static readonly dnsHole = new DnsHoleMockService();
|
||||||
private static readonly calendar = new CalendarMockService();
|
private static readonly calendar = new CalendarMockService();
|
||||||
private static readonly downloadClient = new DownloadClientMockService();
|
private static readonly downloadClient = new DownloadClientMockService();
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
|
|||||||
[value].flat().filter((item): item is TValue => item !== undefined);
|
[value].flat().filter((item): item is TValue => item !== undefined);
|
||||||
|
|
||||||
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
|
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
|
||||||
|
public async getHttpAuthenticationAsync() {
|
||||||
|
return { query: this.getAuthParams(), redactValues: [] };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const url = this.url("/rest/ping.view", this.getAuthParams());
|
const url = this.url("/rest/ping.view", this.getAuthParams());
|
||||||
const response = await input.fetchAsync(url);
|
const response = await input.fetchAsync(url);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { ResponseError } from "@homarr/common/server";
|
|||||||
import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
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 { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||||
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
|
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
@@ -57,6 +59,10 @@ dayjs.extend(timezone);
|
|||||||
|
|
||||||
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
|
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
|
||||||
export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration {
|
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> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options));
|
const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options));
|
||||||
await client.login();
|
await client.login();
|
||||||
@@ -168,12 +174,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
|
|||||||
public async getNotificationsAsync(): Promise<Notification[]> {
|
public async getNotificationsAsync(): Promise<Notification[]> {
|
||||||
const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" });
|
const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" });
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${this.getSecretValue("username")}:${this.getSecretValue("password")}`,
|
|
||||||
).toString("base64")}`,
|
|
||||||
"OCS-APIRequest": "true",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new ResponseError(response);
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
@@ -9,6 +10,10 @@ import type { INotificationsIntegration } from "../interfaces/notifications/noti
|
|||||||
import { ntfyNotificationSchema } from "./ntfy-schema";
|
import { ntfyNotificationSchema } from "./ntfy-schema";
|
||||||
|
|
||||||
export class NTFYIntegration extends Integration implements INotificationsIntegration {
|
export class NTFYIntegration extends Integration implements INotificationsIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() });
|
await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -17,7 +22,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
|
|||||||
private getTopicURL() {
|
private getTopicURL() {
|
||||||
return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 });
|
return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 });
|
||||||
}
|
}
|
||||||
private getHeaders() {
|
private getHeaders(): Record<string, string> {
|
||||||
return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {};
|
return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
|
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
|
||||||
|
|
||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
@@ -20,6 +21,18 @@ type SessionStoreValue =
|
|||||||
| { type: "cookie"; loginToken: string; sessionId: string };
|
| { type: "cookie"; loginToken: string; sessionId: string };
|
||||||
|
|
||||||
export class OpenMediaVaultIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
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>;
|
private readonly sessionStore: SessionStore<SessionStoreValue>;
|
||||||
|
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -22,6 +23,10 @@ import {
|
|||||||
} from "./opnsense-types";
|
} from "./opnsense-types";
|
||||||
|
|
||||||
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
|
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: { Authorization: this.getAuthHeaders() } };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
|
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ResponseError } from "@homarr/common/server";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
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 type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { ISearchableIntegration } from "../base/searchable-integration";
|
import type { ISearchableIntegration } from "../base/searchable-integration";
|
||||||
@@ -40,11 +42,13 @@ export class OverseerrIntegration
|
|||||||
extends Integration
|
extends Integration
|
||||||
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
|
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
|
||||||
{
|
{
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
public async searchAsync(query: string) {
|
public async searchAsync(query: string) {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const schemaData = await searchSchema.parseAsync(await response.json());
|
const schemaData = await searchSchema.parseAsync(await response.json());
|
||||||
|
|
||||||
@@ -69,9 +73,7 @@ export class OverseerrIntegration
|
|||||||
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
|
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 url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const data = await mediaInformationSchema.parseAsync(await response.json());
|
const data = await mediaInformationSchema.parseAsync(await response.json());
|
||||||
const requestedSeasons = [
|
const requestedSeasons = [
|
||||||
@@ -110,9 +112,7 @@ export class OverseerrIntegration
|
|||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/v1/auth/me"), {
|
const response = await input.fetchAsync(this.url("/api/v1/auth/me"), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
@@ -126,18 +126,14 @@ export class OverseerrIntegration
|
|||||||
const pendingRequests = await fetchWithTrustedCertificatesAsync(
|
const pendingRequests = await fetchWithTrustedCertificatesAsync(
|
||||||
this.url("/api/v1/request", { take: 20, filter: "pending" }),
|
this.url("/api/v1/request", { take: 20, filter: "pending" }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const allRequests = await fetchWithTrustedCertificatesAsync(
|
const allRequests = await fetchWithTrustedCertificatesAsync(
|
||||||
this.url("/api/v1/request", { take: 20, sort: "modified" }),
|
this.url("/api/v1/request", { take: 20, sort: "modified" }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -234,18 +230,14 @@ export class OverseerrIntegration
|
|||||||
|
|
||||||
public async getStatsAsync(): Promise<RequestStats> {
|
public async getStatsAsync(): Promise<RequestStats> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return await getStatsSchema.parseAsync(await response.json());
|
return await getStatsSchema.parseAsync(await response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUsersAsync(): Promise<RequestUser[]> {
|
public async getUsersAsync(): Promise<RequestUser[]> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const users = (await getUsersSchema.parseAsync(await response.json())).results;
|
const users = (await getUsersSchema.parseAsync(await response.json())).results;
|
||||||
return users.map((user): RequestUser => {
|
return users.map((user): RequestUser => {
|
||||||
@@ -261,9 +253,7 @@ export class OverseerrIntegration
|
|||||||
logger.info("Approving media request", { requestId, integration: this.integration.name });
|
logger.info("Approving media request", { requestId, integration: this.integration.name });
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new ResponseError(response);
|
if (!response.ok) throw new ResponseError(response);
|
||||||
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
|
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
|
||||||
@@ -274,9 +264,7 @@ export class OverseerrIntegration
|
|||||||
|
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new ResponseError(response);
|
if (!response.ok) throw new ResponseError(response);
|
||||||
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
|
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
|
||||||
@@ -284,9 +272,7 @@ export class OverseerrIntegration
|
|||||||
|
|
||||||
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
|
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
|
||||||
headers: {
|
headers: (await this.getHttpAuthenticationAsync()).headers,
|
||||||
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -9,6 +10,10 @@ import type { PaperlessNgxStats } from "./paperless-ngx-types";
|
|||||||
import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types";
|
import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types";
|
||||||
|
|
||||||
export class PaperlessNgxIntegration extends Integration {
|
export class PaperlessNgxIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const statisticsUrl = this.url("/api/statistics/");
|
const statisticsUrl = this.url("/api/statistics/");
|
||||||
const statisticsResponse = await input.fetchAsync(statisticsUrl, {
|
const statisticsResponse = await input.fetchAsync(statisticsUrl, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -11,6 +12,10 @@ import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-ty
|
|||||||
const STATS_REQUEST_TIMEOUT_MS = 10_000;
|
const STATS_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export class PatchMonIntegration extends Integration {
|
export class PatchMonIntegration extends Integration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
|
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -11,6 +12,10 @@ import type { PeaNutDevice } from "./peanut-types";
|
|||||||
import { peaNutDevicesSchema } from "./peanut-types";
|
import { peaNutDevicesSchema } from "./peanut-types";
|
||||||
|
|
||||||
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
|
export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return { headers: this.getAuthHeaders() };
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
|
const response = await input.fetchAsync(this.url("/api/v1/devices"), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: this.getAuthHeaders(),
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-
|
|||||||
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
|
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
|
||||||
|
|
||||||
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
|
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync() {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (this.hasSecretValue("apiKey")) query.auth = this.getSecretValue("apiKey");
|
||||||
|
return { query };
|
||||||
|
}
|
||||||
|
|
||||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||||
const apiKey = super.getSecretValue("apiKey");
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
|
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } fr
|
|||||||
const logger = createLogger({ module: "piHoleIntegration", version: "v6" });
|
const logger = createLogger({ module: "piHoleIntegration", version: "v6" });
|
||||||
|
|
||||||
export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration {
|
export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(refresh = false) {
|
||||||
|
if (!this.hasSecretValue("apiKey")) return { headers: {} as Record<string, string> };
|
||||||
|
if (refresh) await this.sessionStore.clearAsync();
|
||||||
|
let session = await this.sessionStore.getAsync();
|
||||||
|
if (!session) {
|
||||||
|
session = { sid: await this.getSessionAsync() };
|
||||||
|
await this.sessionStore.setAsync(session);
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (session.sid) headers.sid = session.sid;
|
||||||
|
return { headers };
|
||||||
|
}
|
||||||
|
|
||||||
private readonly sessionStore: SessionStore<{ sid: string | null }>;
|
private readonly sessionStore: SessionStore<{ sid: string | null }>;
|
||||||
|
|
||||||
constructor(integration: IntegrationInput) {
|
constructor(integration: IntegrationInput) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ImageProxy } from "@homarr/image-proxy";
|
import { ImageProxy } from "@homarr/image-proxy";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
|
import { headerAuth } from "../http-auth";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -50,6 +52,10 @@ function isStreamDirect(decision: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return headerAuth("X-Plex-Token")(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||||
const token = super.getSecretValue("apiKey");
|
const token = super.getSecretValue("apiKey");
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
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 type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
@@ -11,6 +13,10 @@ import type { Indexer } from "../interfaces/indexer-manager/indexer-manager-type
|
|||||||
import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types";
|
import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types";
|
||||||
|
|
||||||
export class ProwlarrIntegration extends Integration implements IIndexerManagerIntegration {
|
export class ProwlarrIntegration extends Integration implements IIndexerManagerIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return apiKeyAuth(this.integration);
|
||||||
|
}
|
||||||
|
|
||||||
public async getIndexersAsync(): Promise<Indexer[]> {
|
public async getIndexersAsync(): Promise<Indexer[]> {
|
||||||
const apiKey = super.getSecretValue("apiKey");
|
const apiKey = super.getSecretValue("apiKey");
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import proxmoxApi from "proxmox-api";
|
|||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
import type { IntegrationHttpAuthentication } from "../http-auth";
|
||||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -23,6 +24,14 @@ const logger = createLogger({ module: "proxmoxIntegration" });
|
|||||||
|
|
||||||
@HandleIntegrationErrors([new ProxmoxApiErrorHandler()])
|
@HandleIntegrationErrors([new ProxmoxApiErrorHandler()])
|
||||||
export class ProxmoxIntegration extends Integration implements IClusterHealthMonitoringIntegration {
|
export class ProxmoxIntegration extends Integration implements IClusterHealthMonitoringIntegration {
|
||||||
|
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `PVEAPIToken=${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}=${this.getSecretValue("apiKey")}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const proxmox = this.getPromoxApi(input.fetchAsync);
|
const proxmox = this.getPromoxApi(input.fetchAsync);
|
||||||
await proxmox.nodes.$get();
|
await proxmox.nodes.$get();
|
||||||
|
|||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user