Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
// under the License.

import { useState } from "react";
import { Box, Button, Typography } from "@wso2/oxygen-ui";
import { Box, Button, Skeleton, Typography } from "@wso2/oxygen-ui";
import DOMPurify from "dompurify";
import { useResolvedInlineImageHtml } from "@utils/useResolvedInlineImageHtml";

// Some comments (state-change audit entries, etc.) carry real HTML (`<br><p>...</p>`) rather than
// plain text — rendering those as plain text shows the literal tags. Same sniff-then-sanitize
// pattern as the webapp's UpdatesPage (apps/csm-portal/webapp), which hits the same ambiguity.
const HTML_FORMAT_RE = /<\/?(p|span|div|ul|ol|li|strong|em|b|i|br|h[1-6]|a[\s>]|table|tr|td|th|code|pre|blockquote)\b/i;
const HTML_FORMAT_RE =
/<\/?(p|span|div|ul|ol|li|strong|em|b|i|br|h[1-6]|a[\s>]|table|tr|td|th|code|pre|blockquote|img)\b/i;

const HTML_CONTENT_SX = {
fontSize: "0.875rem",
Expand Down Expand Up @@ -50,17 +52,28 @@ export function CommentBody({ content }: { content: string }) {
const truncated = isLong && !expanded;
const shown = truncated ? content.slice(0, TRUNCATE_AT) : content;

// Sanitize the sliced content on its own, then append the ellipsis outside the sanitized HTML
// — appending it before sanitizing risks DOMPurify swallowing it while repairing a tag the
// slice cut through mid-way.
const sanitized = isHtml ? DOMPurify.sanitize(shown) : "";
// Resolve `.iix` inline-image references against the already-sanitized HTML, mirroring the
// webapp's CsmCaseCommentBubble (sanitize, then useResolvedInlineImageHtml on the result) —
// comment/description HTML embeds inline images as auth-gated `.iix` refs the WebView can't
// fetch directly; nothing rendered this before, so images silently never appeared.
const { resolvedHtml, isLoading: imagesLoading } = useResolvedInlineImageHtml(sanitized);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Box sx={{ overflowWrap: "anywhere" }}>
{isHtml ? (
<>
{/* Sanitize the sliced content on its own, then append the ellipsis outside the
* sanitized HTML — appending it before sanitizing risks DOMPurify swallowing it while
* repairing a tag the slice cut through mid-way. */}
<Box
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(shown) }}
sx={{ ...HTML_CONTENT_SX, color: "text.primary" }}
/>
{imagesLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
<Box
dangerouslySetInnerHTML={{ __html: resolvedHtml }}
sx={{ ...HTML_CONTENT_SX, color: "text.primary" }}
/>
)}
{truncated && (
<Typography variant="body2" color="text.primary">
…
Expand Down
24 changes: 20 additions & 4 deletions apps/csm-portal/microapp/src/pages/UpdatesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import DOMPurify from "dompurify";
import { DialogPaper } from "@components/common/DialogPaper";
import { updates } from "@src/services/updates";
import type { ProductUpdateLevel, SearchUpdatesInput, UpdateDescription, UpdateLevelGroup } from "@src/types";
import { useResolvedInlineImageHtml } from "@utils/useResolvedInlineImageHtml";

// The Acrylic theme renders popup papers translucent, so a dropdown reads as
// see-through unless forced opaque — same fix as TimeCardFiltersSheet /
Expand All @@ -53,14 +54,29 @@ const INITIAL_FILTER: FilterState = { productName: "", productVersion: "", start
// Real HTML tags that warrant sanitized HTML rendering vs a plain-text fallback
// — update descriptions come back as HTML sometimes and plain text other
// times, from the same upstream field (see the webapp's identical gotcha).
const HTML_FORMAT_RE = /<\/?(p|span|div|ul|ol|li|strong|em|b|i|br|h[1-6]|a[\s>]|table|tr|td|th|code|pre|blockquote)\b/i;
const HTML_FORMAT_RE =
/<\/?(p|span|div|ul|ol|li|strong|em|b|i|br|h[1-6]|a[\s>]|table|tr|td|th|code|pre|blockquote|img)\b/i;

function HtmlOrText({ content }: { content: string }) {
if (HTML_FORMAT_RE.test(content)) {
const isHtml = HTML_FORMAT_RE.test(content);
// Sanitize first, then resolve `.iix` inline-image references against the sanitized HTML —
// same order and reasoning as CommentBody.tsx: update descriptions embed inline images the same
// way comments do, and nothing resolved them before.
const sanitized = isHtml ? DOMPurify.sanitize(content) : "";
const { resolvedHtml, isLoading: imagesLoading } = useResolvedInlineImageHtml(sanitized);

if (isHtml) {
if (imagesLoading) return <Skeleton variant="rounded" height={80} />;
return (
<Box
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
sx={{ fontSize: "0.875rem", lineHeight: 1.6, color: "text.secondary", "& p": { m: "0 0 0.4em 0" } }}
dangerouslySetInnerHTML={{ __html: resolvedHtml }}
sx={{
fontSize: "0.875rem",
lineHeight: 1.6,
color: "text.secondary",
"& p": { m: "0 0 0.4em 0" },
"& img": { maxWidth: "100%", height: "auto", display: "block" },
}}
/>
);
}
Expand Down
11 changes: 11 additions & 0 deletions apps/csm-portal/microapp/src/services/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ const getAttachmentContent = async (attachment: CaseAttachment): Promise<Blob> =
return data.type === safeType ? data : data.slice(0, data.size, safeType);
};

// For inline `<img>` references embedded in comment/description HTML, where there's no
// CaseAttachment metadata to cross-check a claimed content type against (see
// useResolvedInlineImageHtml) — the backend already coerces unsafe upstream content types to
// application/octet-stream server-side (case_handler.go), so the blob's own reported type is
// trustworthy as-is.
const getAttachmentContentById = async (id: string): Promise<Blob> => {
const { data } = await apiClient.get<Blob>(ATTACHMENT_CONTENT_ENDPOINT(id), { responseType: "blob" });
return data;
};

export const attachments = {
create: createAttachment,
forCase: (caseId: string) =>
Expand All @@ -74,4 +84,5 @@ export const attachments = {
queryFn: () => searchAttachments(caseId, "case"),
}),
getContent: getAttachmentContent,
getContentById: getAttachmentContentById,
};
91 changes: 91 additions & 0 deletions apps/csm-portal/microapp/src/utils/inlineImages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// Ported from the webapp's identical utility (features/csm-cases/utils/inlineImages.ts) — both
// apps talk to the same csm-portal backend, which embeds inline comment/description images the
// same way regardless of client.

// Shared "img tag with quoted/bare src" grammar for both extraction and
// replacement, so the two stay in sync as the pattern evolves. Capture groups:
// 1=before-src attrs, 2=double-quoted src, 3=single-quoted src, 4=bare src, 5=after-src attrs.
const IMG_TAG_SRC = /<img([^>]*?)\s+src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))([^>]*)>/gi;

/**
* Extracts the backing attachment id from an inline `<img>` `src` value. The
* backing data source embeds inline images as `.iix`-suffixed references
* (e.g. `.../<attachmentId>.iix`); this pulls the id out regardless of
* whether it appears as a full path or a bare token.
*/
export function extractInlineImageRefId(src: string): string {
const s = src.trim();
const fromPath = s.match(/\/([a-f0-9]{32})\.iix(?:\?|#|$)/i);
if (fromPath) return fromPath[1];
const tail =
s
.replace(/\.iix$/i, "")
.split("/")
.pop()
?.trim() ?? "";
if (/^[a-f0-9]{32}$/i.test(tail)) return tail;
return s
.replace(/^\//, "")
.replace(/\.iix$/i, "")
.trim();
}

/**
* Formats a 32-character ServiceNow sysid (no hyphens) as a canonical UUID
* (`8-4-4-4-12`) — the shape the backend's `/attachments/{id}/content`
* endpoint requires. Ids already in another shape are returned unchanged.
*/
export function sysidToUuid(id: string): string {
if (!/^[a-f0-9]{32}$/i.test(id)) return id;
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;
}

/** Extracts every attachment id referenced by a `.iix` `<img>` src within an HTML string. */
export function extractIixAttachmentIds(html: string): string[] {
const ids: string[] = [];
let match;
IMG_TAG_SRC.lastIndex = 0;
while ((match = IMG_TAG_SRC.exec(html)) !== null) {
const src = match[2] ?? match[3] ?? match[4] ?? "";
if (src.includes(".iix")) {
const id = extractInlineImageRefId(src);
if (id && !ids.includes(id)) ids.push(id);
}
}
return ids;
}

/**
* Replaces every `.iix` `<img>` src in `html` with its resolved data URL from
* `dataUrls`. A `.iix` reference with no matching entry is stripped (rather
* than left pointing at an auth-gated URL the browser cannot fetch).
*/
export function replaceInlineImageSrcs(html: string, dataUrls: Map<string, string>): string {
return html.replace(IMG_TAG_SRC, (fullMatch, before, doubleSrc, singleSrc, bareSrc, after) => {
const src = (doubleSrc ?? singleSrc ?? bareSrc ?? "") as string;
if (!src.includes(".iix")) return fullMatch;
const refId = extractInlineImageRefId(src);
const dataUrl = dataUrls.get(refId);
const quote = doubleSrc !== undefined ? '"' : singleSrc !== undefined ? "'" : '"';
if (!dataUrl) {
return `<img${before} src=${quote}${quote} data-unresolved="true"${after}>`;
}
return `<img${before} src=${quote}${dataUrl}${quote}${after}>`;
});
}
99 changes: 99 additions & 0 deletions apps/csm-portal/microapp/src/utils/useResolvedInlineImageHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

import { useMemo } from "react";
import { useQueries } from "@tanstack/react-query";
import { attachments } from "@src/services/attachments";
import { extractIixAttachmentIds, replaceInlineImageSrcs, sysidToUuid } from "./inlineImages";

const SAFE_IMAGE_SUBTYPES = /^(png|jpeg|jpg|gif|webp|svg\+xml|bmp|avif)$/i;

/** Returns the normalized image MIME type, or `null` if `raw` isn't an allowed image subtype. */
function toSafeMimeType(raw: string): string | null {
const lower = raw.trim().toLowerCase();
const fullMatch = lower.match(/^image\/(.+)$/);
if (fullMatch && SAFE_IMAGE_SUBTYPES.test(fullMatch[1])) return lower;
if (SAFE_IMAGE_SUBTYPES.test(lower)) return `image/${lower}`;
return null;
}

function blobToDataUrl(blob: Blob): Promise<string | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result;
resolve(typeof result === "string" ? result : null);
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
}

/**
* Fetches inline-image attachments referenced within comment/description HTML via the
* authenticated `GET /attachments/{id}/content` endpoint and resolves them into `data:` URLs so
* `<img>` tags can render them without the WebView making an unauthenticated request. Ported from
* the webapp's identical hook (features/csm-cases/api/useResolvedInlineImageHtml.ts) — both apps
* read the same csm-portal backend and the same `.iix` inline-image convention.
*
* @param html - Sanitized HTML that may contain `.iix` `<img>` src references.
*/
export function useResolvedInlineImageHtml(html: string): { resolvedHtml: string; isLoading: boolean } {
const attachmentIds = useMemo(() => extractIixAttachmentIds(html), [html]);

const queries = useQueries({
queries: attachmentIds.map((id) => ({
queryKey: ["attachment", "inline-preview", id],
queryFn: async (): Promise<string | null> => {
// The extracted id is a bare 32-char sysid; the content endpoint requires the canonical
// UUID shape (hyphens re-inserted), same as every other attachment id sent to this
// backend.
const blob = await attachments.getContentById(sysidToUuid(id));
const mimeType = toSafeMimeType(blob.type);
if (!mimeType) return null;
return blobToDataUrl(blob);
},
enabled: !!id,
// Attachment content is immutable once uploaded, so cache it indefinitely rather than
// refetching on every remount.
staleTime: Infinity,
retry: 1,
})),
});

const isLoading = queries.some((q) => q.isLoading);

const dataUrls = new Map<string, string>();
attachmentIds.forEach((id, i) => {
const result = queries[i]?.data;
if (result) dataUrls.set(id, result);
});

// A fixed-length key derived from the resolved data URLs: useMemo's dependency array must stay
// the same length across renders, which `queries.map((q) => q.data)` cannot guarantee as
// attachmentIds changes.
const dataUrlsKey = Array.from(dataUrls.entries())
.map(([id, url]) => `${id}:${url}`)
.join(",");

const resolvedHtml = useMemo(
() => replaceInlineImageSrcs(html, dataUrls),
// eslint-disable-next-line react-hooks/exhaustive-deps
[html, dataUrlsKey],
);

return { resolvedHtml, isLoading: attachmentIds.length > 0 && isLoading };
}