diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..2f610328a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[searchParams.kind].category.flat(); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return (
void handleSubmitAsync(value))}> diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 3b79dafd6..1c0c4100a 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP ), integrationSupport: "supportedIntegrations" in currentDefinition, settings, + appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { title(t) { diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..4375644cc 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } + // Media request search is a first-class Spotlight action. Do not create a search engine for it. }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({ const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), + getMediaRequestOptions: protectedProcedure + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +770,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx index 05c7e3f6f..2bce21034 100644 --- a/packages/forms-collection/src/new-app/_form.tsx +++ b/packages/forms-collection/src/new-app/_form.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChangeEventHandler } from "react"; -import { useEffect, useRef } from "react"; +import { useEffect, useImperativeHandle, useRef } from "react"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import type { z } from "zod/v4"; @@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher"; type FormType = z.infer; +const toFormValues = (values: FormType | undefined): FormType => ({ + name: values?.name ?? "", + description: values?.description ?? "", + iconUrl: values?.iconUrl ?? "", + href: values?.href ?? "", + pingUrl: values?.pingUrl ?? "", +}); + +export interface AppFormHandle { + submit: () => Promise; + isDirty: () => boolean; +} + interface AppFormProps { showBackToOverview: boolean; buttonLabels: { @@ -24,8 +37,10 @@ interface AppFormProps { submitAndCreateAnother?: string; }; initialValues?: FormType; - handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void; + handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise; isPending: boolean; + hideButtons?: boolean; + formRef?: React.Ref; } export const AppForm = ({ @@ -34,19 +49,31 @@ export const AppForm = ({ handleSubmit: originalHandleSubmit, initialValues, isPending, + hideButtons, + formRef, }: AppFormProps) => { const t = useI18n(); const form = useZodForm(appManageSchema, { - initialValues: { - name: initialValues?.name ?? "", - description: initialValues?.description ?? "", - iconUrl: initialValues?.iconUrl ?? "", - href: initialValues?.href ?? "", - pingUrl: initialValues?.pingUrl ?? "", - }, + initialValues: toFormValues(initialValues), }); + const initialValuesKey = [ + initialValues?.name, + initialValues?.description, + initialValues?.iconUrl, + initialValues?.href, + initialValues?.pingUrl, + ].join("\0"); + + useEffect(() => { + if (!initialValues || form.isDirty()) { + return; + } + + form.initialize(toFormValues(initialValues)); + }, [initialValuesKey, form, initialValues]); + // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(form.values.name, 200); @@ -62,6 +89,29 @@ export const AppForm = ({ originalHandleSubmit(values, redirect, afterSuccess); }; + useImperativeHandle( + formRef, + () => ({ + submit: () => + new Promise((resolve) => { + form.onSubmit( + async (values) => { + try { + await Promise.resolve(handleSubmit(values)); + form.initialize(values); + resolve(true); + } catch { + resolve(false); + } + }, + () => resolve(false), + )(); + }), + isDirty: () => form.isDirty(), + }), + [form, handleSubmit], + ); + const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0); const handleClickDifferentUrlPing: ChangeEventHandler = () => { @@ -92,32 +142,32 @@ export const AppForm = ({ } }, [debouncedName, iconsData]); - return ( - - - - -