merge: origin/dev (widget-only integration refactor)
Resolve conflicts keeping our simplified request handlers (with TTL cache) and adopting dev's new release-provider + timetable direct implementations. Remove per-query staleTime from timetable widget.
This commit is contained in:
91 files changed
+2306
-2545
No files matched your search
@@ -17,6 +17,7 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||
|
||||
export const mcpRouter = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
@@ -35,4 +36,5 @@ export const mcpRouter = createTRPCRouter({
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
widgetSecrets: widgetSecretsRouter,
|
||||
});
|
||||
@@ -270,78 +270,4 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("with input of existing github app", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
|
||||
|
||||
const integration = {
|
||||
id: "new",
|
||||
name: "GitHub",
|
||||
url: "https://api.github.com",
|
||||
kind: "github" as const,
|
||||
secrets: [
|
||||
{
|
||||
kind: "githubAppId" as const,
|
||||
value: "345",
|
||||
},
|
||||
{
|
||||
kind: "githubInstallationId" as const,
|
||||
value: "456",
|
||||
},
|
||||
{
|
||||
kind: "privateKey" as const,
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dbSecrets = [
|
||||
{
|
||||
kind: "githubAppId" as const,
|
||||
value: "123.encrypted" as const,
|
||||
},
|
||||
{
|
||||
kind: "githubInstallationId" as const,
|
||||
value: "234.encrypted" as const,
|
||||
},
|
||||
{
|
||||
kind: "privateKey" as const,
|
||||
value: "privateKey.encrypted" as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
await testConnectionAsync(integration, dbSecrets);
|
||||
|
||||
// Assert
|
||||
expect(factorySpy).toHaveBeenCalledWith({
|
||||
id: "new",
|
||||
name: "GitHub",
|
||||
url: "https://api.github.com",
|
||||
kind: "github" as const,
|
||||
decryptedSecrets: [
|
||||
expect.objectContaining({
|
||||
kind: "githubAppId",
|
||||
value: "345",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "githubInstallationId",
|
||||
value: "456",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "privateKey",
|
||||
value: "privateKey",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
});
|
||||
@@ -1,14 +1,15 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { escapeForRegEx } from "@tiptap/react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, widgetSecrets } from "@homarr/db/schema";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
import { releasesRequestHandler } from "@homarr/request-handler/releases";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "releases" });
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
@@ -28,45 +29,83 @@ const releaseVersionFilterSchema = z.object({
|
||||
|
||||
export const releasesRouter = createTRPCRouter({
|
||||
getLatest: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
|
||||
.input(
|
||||
z.object({
|
||||
itemId: z.string().optional(),
|
||||
repositories: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
id: z.string().optional(),
|
||||
provider: z.enum(releaseProviderKinds),
|
||||
identifier: z.string(),
|
||||
versionFilter: releaseVersionFilterSchema.optional(),
|
||||
providerUrl: z.string().url().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settled = await Promise.allSettled(
|
||||
input.repositories.map(async (repository) => {
|
||||
const response = await releasesRequestHandler
|
||||
.handler(ctx.integration, {
|
||||
id: repository.id,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
})
|
||||
.getDataAsync();
|
||||
const tokensByProvider = new Map<string, string>();
|
||||
let allowedRepoIds: Set<string> | null = null;
|
||||
|
||||
return {
|
||||
id: repository.id,
|
||||
integration: { name: ctx.integration.name, kind: ctx.integration.kind },
|
||||
timestamp: response.timestamp,
|
||||
...response.data,
|
||||
};
|
||||
if (input.itemId) {
|
||||
try {
|
||||
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
|
||||
if (item && item.kind === "releases") {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
|
||||
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
const repos = options.repositories as Array<{ id?: string }> | undefined;
|
||||
if (repos) {
|
||||
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
|
||||
}
|
||||
|
||||
const secrets = await ctx.db.query.widgetSecrets.findMany({
|
||||
where: eq(widgetSecrets.itemId, input.itemId),
|
||||
});
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
|
||||
} catch {
|
||||
// Skip corrupt secrets
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Access denied or table not yet migrated -- proceed without tokens
|
||||
}
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
input.repositories.map(async (repository) => {
|
||||
try {
|
||||
const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id);
|
||||
const response = await releasesRequestHandler
|
||||
.handler({
|
||||
id: repository.id,
|
||||
provider: repository.provider,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
providerUrl: repository.providerUrl,
|
||||
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
return {
|
||||
id: repository.id,
|
||||
provider: repository.provider,
|
||||
timestamp: response.timestamp,
|
||||
...response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: repository.id,
|
||||
provider: repository.provider,
|
||||
timestamp: new Date(),
|
||||
success: false as const,
|
||||
error: { code: "unexpected" as const, message: String(error) },
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return [result.value];
|
||||
logger.warn("Release fetch failed", {
|
||||
repository: input.repositories[index]?.identifier,
|
||||
error: result.reason,
|
||||
});
|
||||
return [];
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,21 +1,26 @@
|
||||
import z from "zod";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { timetableOptionsSchema } from "@homarr/integrations/types";
|
||||
import {
|
||||
timetableGetTimetableRequestHandler,
|
||||
timetableSearchStationsRequestHandler,
|
||||
} from "@homarr/request-handler/timetable";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const baseUrlSchema = z.string().url();
|
||||
|
||||
export const timetableRouter = createTRPCRouter({
|
||||
getTimetable: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
|
||||
.input(timetableOptionsSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const innerHandler = timetableGetTimetableRequestHandler.handler(ctx.integration, {
|
||||
.input(
|
||||
z.object({
|
||||
baseUrl: baseUrlSchema,
|
||||
stationId: z.string(),
|
||||
limit: z.number().int().min(1).max(100),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = timetableGetTimetableRequestHandler.handler({
|
||||
baseUrl: input.baseUrl,
|
||||
stationId: input.stationId,
|
||||
limit: input.limit,
|
||||
});
|
||||
@@ -25,14 +30,15 @@ export const timetableRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
searchStations: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
|
||||
.input(
|
||||
z.object({
|
||||
baseUrl: baseUrlSchema,
|
||||
query: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const innerHandler = timetableSearchStationsRequestHandler.handler(ctx.integration, {
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = timetableSearchStationsRequestHandler.handler({
|
||||
baseUrl: input.baseUrl,
|
||||
query: input.query,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { boards, items, widgetSecrets } from "@homarr/db/schema";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
const secretKindSchema = z.enum(releaseProviderKinds);
|
||||
|
||||
const resolveItemWithBoardAccess = async (ctx: { db: any; session: any }, itemId: string) => {
|
||||
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
if (!item || item.kind !== "releases") throw new TRPCError({ code: "NOT_FOUND" });
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
|
||||
return item;
|
||||
};
|
||||
|
||||
export const widgetSecretsRouter = createTRPCRouter({
|
||||
setSecret: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Set an authentication token for a release provider on a specific releases widget. Requires modify permission on the widget's board. Use widget_secrets_getConfiguredKinds to check existing tokens",
|
||||
},
|
||||
})
|
||||
.input(z.object({ itemId: z.string(), kind: secretKindSchema, value: z.string().min(1).max(4096) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await resolveItemWithBoardAccess(ctx, input.itemId);
|
||||
|
||||
const encrypted = encryptSecret(input.value);
|
||||
const row = { itemId: input.itemId, kind: input.kind, value: encrypted, updatedAt: new Date() };
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(schema.widgetSecrets)
|
||||
.where(and(eq(schema.widgetSecrets.itemId, input.itemId), eq(schema.widgetSecrets.kind, input.kind)));
|
||||
await tx.insert(schema.widgetSecrets).values(row);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(widgetSecrets)
|
||||
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)))
|
||||
.run();
|
||||
tx.insert(widgetSecrets).values(row).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
deleteSecret: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Remove an authentication token for a release provider from a specific releases widget. Requires modify permission on the widget's board",
|
||||
},
|
||||
})
|
||||
.input(z.object({ itemId: z.string(), kind: secretKindSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await resolveItemWithBoardAccess(ctx, input.itemId);
|
||||
|
||||
await ctx.db
|
||||
.delete(widgetSecrets)
|
||||
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)));
|
||||
}),
|
||||
|
||||
getConfiguredKinds: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List which release provider kinds have tokens configured for a releases widget. Returns an array of provider kind strings (e.g. github, gitlab, dockerHub). Requires modify permission on the widget's board",
|
||||
},
|
||||
})
|
||||
.input(z.object({ itemId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await resolveItemWithBoardAccess(ctx, input.itemId);
|
||||
|
||||
try {
|
||||
const secrets = await ctx.db.query.widgetSecrets.findMany({
|
||||
where: eq(widgetSecrets.itemId, input.itemId),
|
||||
columns: { kind: true },
|
||||
});
|
||||
return secrets.map((s: { kind: string }) => s.kind);
|
||||
} catch {
|
||||
// ponytail: table may not exist pre-migration, return empty
|
||||
return [] as string[];
|
||||
}
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user