242 lines
9.8 KiB
TypeScript
242 lines
9.8 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod/v4";
|
|
|
|
import { createId } from "@homarr/common";
|
|
import { encryptSecret } from "@homarr/common/server";
|
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
|
|
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
|
import {
|
|
customWidgetCreateSchema,
|
|
customWidgetDefinitionSchema,
|
|
customWidgetUpdateSchema,
|
|
} from "@homarr/custom-widgets/core";
|
|
|
|
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
|
import { insertCustomWidgetDefinition } from "./definition-insert";
|
|
import { managementQueryProcedures } from "./management-queries";
|
|
import { metadataProcedures } from "./metadata-procedures";
|
|
import { previewActionProcedures } from "./preview-action-procedures";
|
|
import { previewBaseProcedures } from "./preview-base-procedures";
|
|
import { previewQueryProcedures } from "./preview-query-procedures";
|
|
import {
|
|
parseStoredCustomWidgetDefinition,
|
|
safeParseStoredCustomWidgetDefinition,
|
|
serializeCustomWidgetDefinition,
|
|
} from "./stored-definition";
|
|
import { templateProcedures } from "./template-procedures";
|
|
import { transferProcedures } from "./transfer-procedures";
|
|
import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
|
import { secretProcedures } from "./secret-procedures";
|
|
import { workshopProcedures } from "./workshop-procedures";
|
|
|
|
const logger = createLogger({ module: "custom-widget" });
|
|
|
|
export const customWidgetRouter = createTRPCRouter({
|
|
...metadataProcedures,
|
|
...managementQueryProcedures,
|
|
|
|
create: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
|
|
.input(customWidgetCreateSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { secrets, ...candidate } = input;
|
|
const definition = customWidgetDefinitionSchema.parse(candidate);
|
|
assertSecretSources(definition.sources, secrets);
|
|
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
|
logger.info("Created custom widget definition", { id, name: definition.name });
|
|
return { id };
|
|
}),
|
|
|
|
update: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
|
|
.input(customWidgetUpdateSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
|
where: eq(customWidgetDefinitions.id, input.id),
|
|
});
|
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
|
|
|
const current = parseStoredCustomWidgetDefinition(existing);
|
|
const { id, secrets, ...changes } = input;
|
|
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
|
if (secrets) assertSecretSources(definition.sources, secrets);
|
|
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
|
const secretRows = secrets?.map((secret) => ({
|
|
definitionId: id,
|
|
sourceId: secret.sourceId,
|
|
kind: secret.kind,
|
|
encryptedValue: encryptSecret(secret.value),
|
|
updatedAt: new Date(),
|
|
}));
|
|
const sourceIds = Object.keys(definition.sources);
|
|
const changedSecretBindings = new Set(
|
|
Object.entries(definition.sources).flatMap(([sourceId, source]) => {
|
|
const previous = current.sources[sourceId];
|
|
return previous && !hasSameSecretBinding(previous, source) ? [sourceId] : [];
|
|
}),
|
|
);
|
|
|
|
await handleTransactionsAsync(ctx.db, {
|
|
async handleAsync(db, schema) {
|
|
await db.transaction(async (transaction) => {
|
|
await transaction
|
|
.update(schema.customWidgetDefinitions)
|
|
.set(definitionChanges)
|
|
.where(eq(schema.customWidgetDefinitions.id, id));
|
|
|
|
await transaction
|
|
.delete(schema.customWidgetSecrets)
|
|
.where(
|
|
and(
|
|
eq(schema.customWidgetSecrets.definitionId, id),
|
|
notInArray(schema.customWidgetSecrets.sourceId, sourceIds),
|
|
),
|
|
);
|
|
|
|
for (const sourceId of changedSecretBindings) {
|
|
await transaction
|
|
.delete(schema.customWidgetSecrets)
|
|
.where(
|
|
and(
|
|
eq(schema.customWidgetSecrets.definitionId, id),
|
|
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
|
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
|
const where = and(
|
|
eq(schema.customWidgetSecrets.definitionId, id),
|
|
eq(schema.customWidgetSecrets.sourceId, sourceId),
|
|
);
|
|
await transaction
|
|
.delete(schema.customWidgetSecrets)
|
|
.where(kinds.length > 0 ? and(where, notInArray(schema.customWidgetSecrets.kind, kinds)) : where);
|
|
}
|
|
|
|
for (const secret of secretRows ?? []) {
|
|
await transaction
|
|
.delete(schema.customWidgetSecrets)
|
|
.where(
|
|
and(
|
|
eq(schema.customWidgetSecrets.definitionId, id),
|
|
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
|
|
eq(schema.customWidgetSecrets.kind, secret.kind),
|
|
),
|
|
);
|
|
await transaction.insert(schema.customWidgetSecrets).values(secret);
|
|
}
|
|
});
|
|
},
|
|
handleSync(db) {
|
|
db.transaction((transaction) => {
|
|
transaction
|
|
.update(customWidgetDefinitions)
|
|
.set(definitionChanges)
|
|
.where(eq(customWidgetDefinitions.id, id))
|
|
.run();
|
|
|
|
transaction
|
|
.delete(customWidgetSecrets)
|
|
.where(and(eq(customWidgetSecrets.definitionId, id), notInArray(customWidgetSecrets.sourceId, sourceIds)))
|
|
.run();
|
|
|
|
for (const sourceId of changedSecretBindings) {
|
|
transaction
|
|
.delete(customWidgetSecrets)
|
|
.where(and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId)))
|
|
.run();
|
|
}
|
|
|
|
for (const [sourceId, source] of Object.entries(definition.sources)) {
|
|
const kinds = [...requiredSecretKinds(typeof source.auth === "string" ? source.auth : source.auth.type)];
|
|
const where = and(eq(customWidgetSecrets.definitionId, id), eq(customWidgetSecrets.sourceId, sourceId));
|
|
transaction
|
|
.delete(customWidgetSecrets)
|
|
.where(kinds.length > 0 ? and(where, notInArray(customWidgetSecrets.kind, kinds)) : where)
|
|
.run();
|
|
}
|
|
|
|
for (const secret of secretRows ?? []) {
|
|
transaction
|
|
.delete(customWidgetSecrets)
|
|
.where(
|
|
and(
|
|
eq(customWidgetSecrets.definitionId, id),
|
|
eq(customWidgetSecrets.sourceId, secret.sourceId),
|
|
eq(customWidgetSecrets.kind, secret.kind),
|
|
),
|
|
)
|
|
.run();
|
|
transaction.insert(customWidgetSecrets).values(secret).run();
|
|
}
|
|
});
|
|
},
|
|
});
|
|
logger.info("Updated custom widget definition", { id });
|
|
}),
|
|
|
|
...secretProcedures,
|
|
...workshopProcedures,
|
|
|
|
toggleEnabled: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.input(z.object({ id: z.string(), enabled: z.boolean() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
if (input.enabled) {
|
|
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
|
|
where: eq(customWidgetDefinitions.id, input.id),
|
|
});
|
|
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
|
|
if (!safeParseStoredCustomWidgetDefinition(definition).success) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Invalid custom widget definition cannot be enabled",
|
|
});
|
|
}
|
|
}
|
|
|
|
await ctx.db
|
|
.update(customWidgetDefinitions)
|
|
.set({ enabled: input.enabled, updatedAt: new Date() })
|
|
.where(eq(customWidgetDefinitions.id, input.id));
|
|
}),
|
|
|
|
delete: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.meta({ mcp: { enabled: true, description: "Delete one Custom JSX widget." } })
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
|
|
logger.info("Deleted custom widget definition", { id: input.id });
|
|
}),
|
|
|
|
duplicate: permissionRequiredProcedure
|
|
.requiresPermission("admin")
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
|
where: eq(customWidgetDefinitions.id, input.id),
|
|
});
|
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
|
const current = parseStoredCustomWidgetDefinition(existing);
|
|
const id = createId();
|
|
await ctx.db.insert(customWidgetDefinitions).values({
|
|
id,
|
|
...serializeCustomWidgetDefinition({ ...current, name: `${current.name} (copy)` }),
|
|
creatorId: ctx.session.user.id,
|
|
});
|
|
return { id, name: `${current.name} (copy)` };
|
|
}),
|
|
|
|
...templateProcedures,
|
|
...transferProcedures,
|
|
...previewBaseProcedures,
|
|
...previewQueryProcedures,
|
|
...previewActionProcedures,
|
|
});
|