diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile index 92f35d170..332f96d28 100644 --- a/apps/workshop/Dockerfile +++ b/apps/workshop/Dockerfile @@ -12,6 +12,7 @@ COPY apps/docs ./apps/docs COPY packages/custom-widgets ./packages/custom-widgets COPY packages/definitions ./packages/definitions COPY packages/workshop ./packages/workshop +COPY scripts/build-workshop-validator.mjs ./scripts/build-workshop-validator.mjs COPY tooling/typescript ./tooling/typescript RUN pnpm config set store-dir /pnpm/store && pnpm install --filter @homarr/docs... --frozen-lockfile RUN pnpm build:workshop-validator diff --git a/packages/api/src/router/custom-widget/secret-persistence.ts b/packages/api/src/router/custom-widget/secret-persistence.ts new file mode 100644 index 000000000..d72dfa8b2 --- /dev/null +++ b/packages/api/src/router/custom-widget/secret-persistence.ts @@ -0,0 +1,205 @@ +import { encryptSecret } from "@homarr/common/server"; +import type { Database } from "@homarr/db"; +import { and, eq, handleTransactionsAsync } from "@homarr/db"; +import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; +import type { + CustomWidgetCreateInput, + CustomWidgetSecretKind, + CustomWidgetSource, + HomarrCustomWidgetV2, +} from "@homarr/custom-widgets/core"; + +import { hasSameSecretBinding } from "./secret-policy"; +import { serializeCustomWidgetDefinition } from "./stored-definition"; + +type CustomWidgetSecretInput = CustomWidgetCreateInput["secrets"][number]; + +export async function setCustomWidgetSecret(db: Database, definitionId: string, secret: CustomWidgetSecretInput) { + const updatedAt = new Date(); + const secretRow = { + definitionId, + sourceId: secret.sourceId, + kind: secret.kind, + encryptedValue: encryptSecret(secret.value), + updatedAt, + }; + await handleTransactionsAsync(db, { + async handleAsync(database, schema) { + await database.transaction(async (transaction) => { + await transaction + .delete(schema.customWidgetSecrets) + .where( + and( + eq(schema.customWidgetSecrets.definitionId, definitionId), + eq(schema.customWidgetSecrets.sourceId, secret.sourceId), + eq(schema.customWidgetSecrets.kind, secret.kind), + ), + ); + await transaction.insert(schema.customWidgetSecrets).values(secretRow); + await transaction + .update(schema.customWidgetDefinitions) + .set({ updatedAt }) + .where(eq(schema.customWidgetDefinitions.id, definitionId)); + }); + }, + handleSync(database) { + database.transaction((transaction) => { + transaction + .delete(customWidgetSecrets) + .where( + and( + eq(customWidgetSecrets.definitionId, definitionId), + eq(customWidgetSecrets.sourceId, secret.sourceId), + eq(customWidgetSecrets.kind, secret.kind), + ), + ) + .run(); + transaction.insert(customWidgetSecrets).values(secretRow).run(); + transaction + .update(customWidgetDefinitions) + .set({ updatedAt }) + .where(eq(customWidgetDefinitions.id, definitionId)) + .run(); + }); + }, + }); +} + +export async function configureCustomWidgetSource( + db: Database, + input: { + definitionId: string; + sourceId: string; + definition: HomarrCustomWidgetV2; + previousSource: CustomWidgetSource; + source: CustomWidgetSource; + secrets: CustomWidgetCreateInput["secrets"]; + }, +) { + const definitionChanges = { + ...serializeCustomWidgetDefinition(input.definition), + updatedAt: new Date(), + }; + const bindingChanged = !hasSameSecretBinding(input.previousSource, input.source); + const secretRows = input.secrets.map((secret) => ({ + definitionId: input.definitionId, + sourceId: input.sourceId, + kind: secret.kind, + encryptedValue: encryptSecret(secret.value), + updatedAt: new Date(), + })); + await handleTransactionsAsync(db, { + async handleAsync(database, schema) { + await database.transaction(async (transaction) => { + await transaction + .update(schema.customWidgetDefinitions) + .set(definitionChanges) + .where(eq(schema.customWidgetDefinitions.id, input.definitionId)); + if (bindingChanged) { + await transaction + .delete(schema.customWidgetSecrets) + .where( + and( + eq(schema.customWidgetSecrets.definitionId, input.definitionId), + eq(schema.customWidgetSecrets.sourceId, input.sourceId), + ), + ); + } + for (const secret of secretRows) { + await transaction + .delete(schema.customWidgetSecrets) + .where( + and( + eq(schema.customWidgetSecrets.definitionId, input.definitionId), + eq(schema.customWidgetSecrets.sourceId, input.sourceId), + eq(schema.customWidgetSecrets.kind, secret.kind), + ), + ); + await transaction.insert(schema.customWidgetSecrets).values(secret); + } + }); + }, + handleSync(database) { + database.transaction((transaction) => { + transaction + .update(customWidgetDefinitions) + .set(definitionChanges) + .where(eq(customWidgetDefinitions.id, input.definitionId)) + .run(); + if (bindingChanged) { + transaction + .delete(customWidgetSecrets) + .where( + and( + eq(customWidgetSecrets.definitionId, input.definitionId), + eq(customWidgetSecrets.sourceId, input.sourceId), + ), + ) + .run(); + } + for (const secret of secretRows) { + transaction + .delete(customWidgetSecrets) + .where( + and( + eq(customWidgetSecrets.definitionId, input.definitionId), + eq(customWidgetSecrets.sourceId, input.sourceId), + eq(customWidgetSecrets.kind, secret.kind), + ), + ) + .run(); + transaction.insert(customWidgetSecrets).values(secret).run(); + } + }); + }, + }); +} + +export async function clearCustomWidgetSecret( + db: Database, + input: { + definitionId: string; + sourceId: string; + kind: CustomWidgetSecretKind; + }, +) { + const updatedAt = new Date(); + await handleTransactionsAsync(db, { + async handleAsync(database, schema) { + await database.transaction(async (transaction) => { + await transaction + .delete(schema.customWidgetSecrets) + .where( + and( + eq(schema.customWidgetSecrets.definitionId, input.definitionId), + eq(schema.customWidgetSecrets.sourceId, input.sourceId), + eq(schema.customWidgetSecrets.kind, input.kind), + ), + ); + await transaction + .update(schema.customWidgetDefinitions) + .set({ updatedAt }) + .where(eq(schema.customWidgetDefinitions.id, input.definitionId)); + }); + }, + handleSync(database) { + database.transaction((transaction) => { + transaction + .delete(customWidgetSecrets) + .where( + and( + eq(customWidgetSecrets.definitionId, input.definitionId), + eq(customWidgetSecrets.sourceId, input.sourceId), + eq(customWidgetSecrets.kind, input.kind), + ), + ) + .run(); + transaction + .update(customWidgetDefinitions) + .set({ updatedAt }) + .where(eq(customWidgetDefinitions.id, input.definitionId)) + .run(); + }); + }, + }); +} diff --git a/packages/api/src/router/custom-widget/secret-procedures.ts b/packages/api/src/router/custom-widget/secret-procedures.ts index b683d0c36..792703052 100644 --- a/packages/api/src/router/custom-widget/secret-procedures.ts +++ b/packages/api/src/router/custom-widget/secret-procedures.ts @@ -1,9 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; -import { encryptSecret } from "@homarr/common/server"; -import { and, eq, handleTransactionsAsync } from "@homarr/db"; -import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; +import { eq } from "@homarr/db"; +import { customWidgetDefinitions } from "@homarr/db/schema"; import { customWidgetSecretInputSchema, customWidgetSecretsInputSchema, @@ -16,9 +15,10 @@ import { createCustomWidgetConfigurationRequest, getCustomWidgetConfigurationRequestForUser, } from "./configuration-requests"; -import { assertSecretSources, hasSameSecretBinding, requiredSecretKinds } from "./secret-policy"; +import { assertSecretSources, requiredSecretKinds } from "./secret-policy"; +import { clearCustomWidgetSecret, configureCustomWidgetSource, setCustomWidgetSecret } from "./secret-persistence"; import { getPreviewSession } from "./preview-sessions"; -import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition"; +import { parseStoredCustomWidgetDefinition } from "./stored-definition"; const secretRequestInputSchema = z .object({ @@ -51,54 +51,7 @@ export const secretProcedures = { if (!existing) throw new TRPCError({ code: "NOT_FOUND" }); const definition = parseStoredCustomWidgetDefinition(existing); assertSecretSources(definition.sources, [input.secret]); - const updatedAt = new Date(); - const secretRow = { - definitionId: input.definitionId, - sourceId: input.secret.sourceId, - kind: input.secret.kind, - encryptedValue: encryptSecret(input.secret.value), - updatedAt, - }; - await handleTransactionsAsync(ctx.db, { - async handleAsync(db, schema) { - await db.transaction(async (transaction) => { - await transaction - .delete(schema.customWidgetSecrets) - .where( - and( - eq(schema.customWidgetSecrets.definitionId, input.definitionId), - eq(schema.customWidgetSecrets.sourceId, input.secret.sourceId), - eq(schema.customWidgetSecrets.kind, input.secret.kind), - ), - ); - await transaction.insert(schema.customWidgetSecrets).values(secretRow); - await transaction - .update(schema.customWidgetDefinitions) - .set({ updatedAt }) - .where(eq(schema.customWidgetDefinitions.id, input.definitionId)); - }); - }, - handleSync(db) { - db.transaction((transaction) => { - transaction - .delete(customWidgetSecrets) - .where( - and( - eq(customWidgetSecrets.definitionId, input.definitionId), - eq(customWidgetSecrets.sourceId, input.secret.sourceId), - eq(customWidgetSecrets.kind, input.secret.kind), - ), - ) - .run(); - transaction.insert(customWidgetSecrets).values(secretRow).run(); - transaction - .update(customWidgetDefinitions) - .set({ updatedAt }) - .where(eq(customWidgetDefinitions.id, input.definitionId)) - .run(); - }); - }, - }); + await setCustomWidgetSecret(ctx.db, input.definitionId, input.secret); return { sourceId: input.secret.sourceId, kind: input.secret.kind, isSet: true }; }), @@ -128,79 +81,13 @@ export const secretProcedures = { }); assertSecretSources({ [input.sourceId]: source }, input.secrets); const updated = { ...definition, sources: { ...definition.sources, [input.sourceId]: source } }; - const definitionChanges = { ...serializeCustomWidgetDefinition(updated), updatedAt: new Date() }; - const bindingChanged = !hasSameSecretBinding(current, source); - const secretRows = input.secrets.map((secret) => ({ + await configureCustomWidgetSource(ctx.db, { definitionId: input.definitionId, sourceId: input.sourceId, - kind: secret.kind, - encryptedValue: encryptSecret(secret.value), - updatedAt: new Date(), - })); - 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, input.definitionId)); - if (bindingChanged) { - await transaction - .delete(schema.customWidgetSecrets) - .where( - and( - eq(schema.customWidgetSecrets.definitionId, input.definitionId), - eq(schema.customWidgetSecrets.sourceId, input.sourceId), - ), - ); - } - for (const secret of secretRows) { - await transaction - .delete(schema.customWidgetSecrets) - .where( - and( - eq(schema.customWidgetSecrets.definitionId, input.definitionId), - eq(schema.customWidgetSecrets.sourceId, input.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, input.definitionId)) - .run(); - if (bindingChanged) { - transaction - .delete(customWidgetSecrets) - .where( - and( - eq(customWidgetSecrets.definitionId, input.definitionId), - eq(customWidgetSecrets.sourceId, input.sourceId), - ), - ) - .run(); - } - for (const secret of secretRows) { - transaction - .delete(customWidgetSecrets) - .where( - and( - eq(customWidgetSecrets.definitionId, input.definitionId), - eq(customWidgetSecrets.sourceId, input.sourceId), - eq(customWidgetSecrets.kind, secret.kind), - ), - ) - .run(); - transaction.insert(customWidgetSecrets).values(secret).run(); - } - }); - }, + definition: updated, + previousSource: current, + source, + secrets: input.secrets, }); return { definitionId: input.definitionId, @@ -283,45 +170,7 @@ export const secretProcedures = { where: eq(customWidgetDefinitions.id, input.definitionId), }); if (!existing) throw new TRPCError({ code: "NOT_FOUND" }); - const updatedAt = new Date(); - await handleTransactionsAsync(ctx.db, { - async handleAsync(db, schema) { - await db.transaction(async (transaction) => { - await transaction - .delete(schema.customWidgetSecrets) - .where( - and( - eq(schema.customWidgetSecrets.definitionId, input.definitionId), - eq(schema.customWidgetSecrets.sourceId, input.sourceId), - eq(schema.customWidgetSecrets.kind, input.kind), - ), - ); - await transaction - .update(schema.customWidgetDefinitions) - .set({ updatedAt }) - .where(eq(schema.customWidgetDefinitions.id, input.definitionId)); - }); - }, - handleSync(db) { - db.transaction((transaction) => { - transaction - .delete(customWidgetSecrets) - .where( - and( - eq(customWidgetSecrets.definitionId, input.definitionId), - eq(customWidgetSecrets.sourceId, input.sourceId), - eq(customWidgetSecrets.kind, input.kind), - ), - ) - .run(); - transaction - .update(customWidgetDefinitions) - .set({ updatedAt }) - .where(eq(customWidgetDefinitions.id, input.definitionId)) - .run(); - }); - }, - }); + await clearCustomWidgetSecret(ctx.db, input); return { sourceId: input.sourceId, kind: input.kind, isSet: false }; }), }; diff --git a/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts b/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts new file mode 100644 index 000000000..41cda5a02 --- /dev/null +++ b/packages/custom-widgets/src/server/request-dispatcher-lifecycle.ts @@ -0,0 +1,59 @@ +import type { createPinnedAgent } from "./network-policy"; + +const DISPATCHER_CLOSE_GRACE_MS = 1_000; + +type CustomWidgetDispatcher = ReturnType; + +export async function closeDispatcher( + dispatcher: CustomWidgetDispatcher, + deadlineSignal: AbortSignal, +): Promise { + const cleanupController = new AbortController(); + const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS); + try { + await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal])); + return true; + } catch (error) { + detachDispatcherDestroy(dispatcher); + if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false; + throw error; + } finally { + clearTimeout(timeout); + } +} + +function detachDispatcherDestroy(dispatcher: CustomWidgetDispatcher): void { + try { + void dispatcher.destroy().catch(() => undefined); + } catch { + // Cleanup is best effort after its bounded grace period. + } +} + +function abortable(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError()); + + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(signal.reason ?? createAbortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + operation.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function createAbortError(): Error { + const error = new Error("Operation aborted"); + error.name = "AbortError"; + return error; +} diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index ddf7d6972..e91499b46 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -12,6 +12,7 @@ import { resolveSameOriginTarget, validateCustomWidgetUrl, } from "./network-policy"; +import { closeDispatcher } from "./request-dispatcher-lifecycle"; import { parseResponseBody } from "./response"; export { @@ -30,7 +31,6 @@ export { export const MAX_REQUEST_BODY_BYTES = 10 * 1024; const REQUEST_TIMEOUT_MS = 10_000; -const DISPATCHER_CLOSE_GRACE_MS = 1_000; const MAX_QUERY_REDIRECTS = 3; 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"]); @@ -213,60 +213,6 @@ async function performRequestWithinDeadline( } } -async function closeDispatcher( - dispatcher: ReturnType, - deadlineSignal: AbortSignal, -): Promise { - const cleanupController = new AbortController(); - const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS); - try { - await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal])); - return true; - } catch (error) { - detachDispatcherDestroy(dispatcher); - if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false; - throw error; - } finally { - clearTimeout(timeout); - } -} - -function detachDispatcherDestroy(dispatcher: ReturnType): void { - try { - void dispatcher.destroy().catch(() => undefined); - } catch { - // Cleanup is best effort after its bounded grace period. - } -} - -function abortable(operation: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError()); - - return new Promise((resolve, reject) => { - const onAbort = () => { - signal.removeEventListener("abort", onAbort); - reject(signal.reason ?? createAbortError()); - }; - signal.addEventListener("abort", onAbort, { once: true }); - operation.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); -} - -function createAbortError(): Error { - const error = new Error("Operation aborted"); - error.name = "AbortError"; - return error; -} - export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean { if (signals.some((signal) => signal.aborted)) return true; if (!(error instanceof Error)) return false;