Files
homarr/packages/api/src/router/custom-widget/transfer-procedures.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

181 lines
7.3 KiB
TypeScript

import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
import { assertCustomWidgetIntegrationBindings } from "./source-resolver";
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema, customWidgetSecretsInputSchema } from "@homarr/custom-widgets/core";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { permissionRequiredProcedure } from "../../trpc";
import { assertSecretSources, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition";
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return exportCustomWidgetDefinition(parseStoredCustomWidgetDefinition(definition));
}),
exportLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
$schema: "homarr-custom-widget-v1" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
configuredSecretKinds: definition.secrets.map(({ kind }) => kind),
};
}),
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const id = await insertCustomWidgetDefinition(ctx.db, input.widget, ctx.session.user.id, input.secrets);
logger.info("Imported custom widget definition", { id, name: input.widget.name });
return { id };
}),
migrateLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Replace one preserved legacy Custom Widget with a validated v2 definition while retaining compatible encrypted credentials.",
},
})
.input(
z.object({
id: z.string(),
widget: customWidgetImportSchema,
secrets: customWidgetSecretsInputSchema.default([]),
}),
)
.mutation(async ({ ctx, input }) => {
await assertCustomWidgetIntegrationBindings(ctx, input.widget.sources);
assertSecretSources(input.widget.sources, input.secrets);
const [legacy, current] = await Promise.all([
ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.id),
with: { secrets: true },
}),
ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id) }),
]);
if (!legacy) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
if (current) throw new TRPCError({ code: "CONFLICT", message: "A v2 widget already uses this identifier" });
const explicitKeys = new Set(input.secrets.map(({ sourceId, kind }) => `${sourceId}:${kind}`));
const preservableKinds = new Set<string>(requiredSecretKinds(getAuthType(input.widget.sources.default?.auth)));
const preservedSecrets = canPreserveLegacySecrets(legacy, input.widget)
? legacy.secrets.filter(({ kind }) => preservableKinds.has(kind) && !explicitKeys.has(`default:${kind}`))
: [];
const now = new Date();
const definitionRow = {
id: legacy.id,
...serializeCustomWidgetDefinition(input.widget),
enabled: legacy.enabled,
createdAt: legacy.createdAt,
updatedAt: now,
creatorId: legacy.creatorId,
};
const secretRows = [
...preservedSecrets.map(({ kind, encryptedValue }) => ({
definitionId: legacy.id,
sourceId: "default",
kind,
encryptedValue,
updatedAt: now,
})),
...input.secrets.map(({ sourceId, kind, value }) => ({
definitionId: legacy.id,
sourceId,
kind,
encryptedValue: encryptSecret(value),
updatedAt: now,
})),
];
await handleTransactionsAsync(ctx.db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction.insert(schema.customWidgetDefinitions).values(definitionRow);
if (secretRows.length > 0) await transaction.insert(schema.customWidgetSecrets).values(secretRows);
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction.insert(customWidgetDefinitions).values(definitionRow).run();
if (secretRows.length > 0) transaction.insert(customWidgetSecrets).values(secretRows).run();
});
},
});
logger.info("Created v2 replacement for preserved legacy custom widget definition", {
id: legacy.id,
preservedSecretCount: preservedSecrets.length,
});
return { id: legacy.id, preservedSecretCount: preservedSecrets.length };
}),
};
function parseLegacyDisplayConfig(value: string): unknown {
try {
return parseSuperJson(value);
} catch {
return value;
}
}
function canPreserveLegacySecrets(
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
widget: z.infer<typeof customWidgetImportSchema>,
) {
const source = widget.sources.default;
if (!source || source.type === "integration" || getAuthType(source.auth) !== legacy.authType) return false;
if (getOrigin(source.baseUrl) !== getOrigin(legacy.url)) return false;
const headerName = typeof source.auth === "object" && "name" in source.auth ? source.auth.name : undefined;
return headerName === (legacy.headerName ?? undefined);
}
function getAuthType(auth: string | { type: string } | undefined) {
return typeof auth === "string" ? auth : (auth?.type ?? "none");
}
function getOrigin(value: string) {
try {
return new URL(value).origin.toLowerCase();
} catch {
return null;
}
}