From 451dc3ce3991dc3f193feaee387c0ef0d69d4835 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 7 Aug 2026 19:18:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20address=20review=20#4=20=E2=80=94=20boar?= =?UTF-8?q?d=20name=20invalidation,=20SVG=20tests,=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass board.name to all invalidateBoardCache calls (permission mutations) - Align integration create invalidation with update/delete - Add initial-fetch-failure test for integration-data-cache - Expand SVG XSS test corpus (javascript:, xlink:href, @import, onbegin) - Forbid '; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("@import"); + }); + + it("strips animate with onbegin handler", () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain("onbegin"); + }); }); diff --git a/apps/nextjs/src/app/api/user-medias/svg-purify.ts b/apps/nextjs/src/app/api/user-medias/svg-purify.ts index d01820d65..4d2af7b24 100644 --- a/apps/nextjs/src/app/api/user-medias/svg-purify.ts +++ b/apps/nextjs/src/app/api/user-medias/svg-purify.ts @@ -3,6 +3,7 @@ import { parseHTML } from "linkedom"; const svgSanitizeOptions = { USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ["style"], } as const; // ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify diff --git a/packages/api/src/integration-data-cache.spec.ts b/packages/api/src/integration-data-cache.spec.ts index 13594f0d9..db90bab78 100644 --- a/packages/api/src/integration-data-cache.spec.ts +++ b/packages/api/src/integration-data-cache.spec.ts @@ -41,6 +41,11 @@ describe("getCachedIntegrationData", () => { vi.useRealTimers(); }); + test("throws on initial fetch failure when no stale data exists", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("upstream down")); + await expect(getCachedIntegrationData("integration-a", "q1", fetcher)).rejects.toThrow("upstream down"); + }); + test("returns stale data when refetch fails", async () => { vi.useFakeTimers(); const fetcher = vi diff --git a/packages/api/src/integration-data-cache.ts b/packages/api/src/integration-data-cache.ts index adcedf77f..10abc35c9 100644 --- a/packages/api/src/integration-data-cache.ts +++ b/packages/api/src/integration-data-cache.ts @@ -1,6 +1,7 @@ // ponytail: module-level cache is single-instance only. // Multi-instance deployments (REDIS_IS_EXTERNAL=true) bypass this entirely — // each request calls the upstream fetcher directly. +// isMultiInstance is read at module load; bypass behavior is verified via code review, not unit tests. const isMultiInstance = process.env.REDIS_IS_EXTERNAL === "true"; interface CachedEntry { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 42422f8d7..cec88bb74 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -604,6 +604,12 @@ export const boardRouter = createTRPCRouter({ .output(z.void()) .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); + + const existingBoard = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.id), + columns: { name: true }, + }); + const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board"); if ( @@ -621,7 +627,7 @@ export const boardRouter = createTRPCRouter({ .set({ isPublic: input.visibility === "public" }) .where(eq(boards.id, input.id)); - invalidateBoardCache(input.id); + invalidateBoardCache(input.id, existingBoard?.name); }), deleteBoard: protectedProcedure .meta({ @@ -852,6 +858,11 @@ export const boardRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); + const existingBoard = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.id), + columns: { name: true }, + }); + await ctx.db .update(boards) .set({ @@ -882,7 +893,7 @@ export const boardRouter = createTRPCRouter({ }) .where(eq(boards.id, input.id)); - invalidateBoardCache(input.id); + invalidateBoardCache(input.id, existingBoard?.name); }), saveBoard: protectedProcedure.input(boardSaveSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); @@ -1377,6 +1388,11 @@ export const boardRouter = createTRPCRouter({ saveUserBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.entityId), + columns: { name: true }, + }); + const existingUserPermissions = await ctx.db.query.boardUserPermissions.findMany({ columns: { userId: true }, where: eq(boardUserPermissions.boardId, input.entityId), @@ -1427,11 +1443,16 @@ export const boardRouter = createTRPCRouter({ for (const userId of userIds) { invalidateUserCache(userId); } - invalidateBoardCache(input.entityId); + invalidateBoardCache(input.entityId, board?.name); }), saveGroupBoardPermissions: protectedProcedure.input(boardSavePermissionsSchema).mutation(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.entityId), + columns: { name: true }, + }); + const existingGroupPermissions = await ctx.db.query.boardGroupPermissions.findMany({ columns: { groupId: true }, where: eq(boardGroupPermissions.boardId, input.entityId), @@ -1490,7 +1511,7 @@ export const boardRouter = createTRPCRouter({ invalidateUserCache(member.userId); } } - invalidateBoardCache(input.entityId); + invalidateBoardCache(input.entityId, board?.name); }), importOldmarrConfig: permissionRequiredProcedure .requiresPermission("board-create") diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 204a7b682..6ecb8c3d9 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -371,6 +371,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); + await invalidateIntegrationCacheAsync(integrationId); invalidateIntegrationCache(integrationId); }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 802a31595..d64ae3bc2 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,4 +12,3 @@ export * from "./error"; export * from "./theme"; export * from "./function"; export * from "./retryable-loader"; -export * from "./id";