diff --git a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts
index 9218bf6b1..17d5b40b1 100644
--- a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts
+++ b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts
@@ -41,4 +41,29 @@ describe("SVG sanitization", () => {
const wrapped = `
${dirty}`;
expect(sanitizeSvg(dirty)).toBe(purify.sanitize(wrapped, svgSanitizeOptions));
});
+
+ it("strips javascript: protocol in href", () => {
+ const dirty = '';
+ const clean = sanitizeSvg(dirty);
+ expect(clean).not.toContain("javascript:");
+ });
+
+ it("strips xlink:href with javascript:", () => {
+ const dirty = '';
+ const clean = sanitizeSvg(dirty);
+ expect(clean).not.toContain("javascript:");
+ });
+
+ it("strips style with url() import", () => {
+ const dirty =
+ '';
+ 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";