fix: the content document

This commit is contained in:
Nathnael
2026-08-09 13:27:05 +00:00
parent 182787e143
commit ff9bb4954a
30 changed files with 1624 additions and 1054 deletions

View File

@@ -1,10 +1,9 @@
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
import { SupportDocSlug } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
@@ -28,17 +27,6 @@ const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
const LINK_MESSAGE =
"$property must start with http(s)://, mailto:, tel: or /";
/**
* A media source is either an uploaded MinIO object key, a same-origin path, or
* an https URL. Anything else — notably `javascript:` — is refused, since this
* value lands in an `<img>`/`<video>` src.
*/
const MEDIA_SRC_PATTERN = new RegExp(
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
);
const MEDIA_SRC_MESSAGE =
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
/* ------------------------------- CONTACT ------------------------------- */
export class PortalSupportContactDto {
@@ -208,58 +196,6 @@ export class PortalFaqContentDto {
/* --------------------------------- HELP -------------------------------- */
export class PortalMediaDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty({ enum: ["image", "video"] })
@IsIn(["image", "video"])
kind!: "image" | "video";
@ApiProperty({
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
})
@IsString()
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
@MaxLength(500)
src!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(300)
caption?: string | null;
}
export class PortalHelpSectionDto {
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(200)
heading!: string;
@ApiProperty({ description: "Markdown" })
@IsString()
@MaxLength(20_000)
body!: string;
@ApiProperty({ type: [PortalMediaDto] })
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => PortalMediaDto)
media!: PortalMediaDto[];
}
export class PortalHelpContentDto {
@ApiProperty()
@IsString()
@@ -272,12 +208,16 @@ export class PortalHelpContentDto {
@MaxLength(500)
subtitle!: string;
@ApiProperty({ type: [PortalHelpSectionDto] })
/**
* Same section shape as the legal documents — images and videos live inside
* the markdown, so a help section needs nothing the others do not have.
*/
@ApiProperty({ type: [PortalDocSectionDto] })
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => PortalHelpSectionDto)
sections!: PortalHelpSectionDto[];
@Type(() => PortalDocSectionDto)
sections!: PortalDocSectionDto[];
}
/* ------------------------------- request ------------------------------- */

View File

@@ -131,7 +131,8 @@ describe("SupportContentService.getBundle", () => {
// findAll returns only CONTACT, so HELP falls back to the defaults — which
// is itself worth asserting: an unseeded row must not break the page.
const bundle = await service.getBundle();
expect(bundle.help.sections[0].media[0].src).toBe(
// A shipped asset path is not a stored object, so nothing is signed.
expect(bundle.help.sections[0].body).toContain(
"/assets/edr-portal-guide.webm",
);
expect(minio.getSignedUrl).not.toHaveBeenCalled();
@@ -141,10 +142,9 @@ describe("SupportContentService.getBundle", () => {
sections: [
{
...help.sections[0],
body: "See ![diagram](minio:support-content/d.png) below.",
media: [
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
],
body:
"See ![diagram](minio:support-content/d.png) and " +
"![clip](minio:support-content/c.mp4).",
},
],
};
@@ -154,14 +154,14 @@ describe("SupportContentService.getBundle", () => {
help: withUpload,
});
expect(signed.help.sections[0].media[0].src).toBe(
"https://minio.test/support-content/a.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/d.png?sig=x",
);
expect(signed.help.sections[0].body).toContain(
"https://minio.test/support-content/c.mp4?sig=x",
);
// The stored copy must never be mutated into a URL — that is what would rot.
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
expect(withUpload.sections[0].body).toContain("minio:support-content/d.png");
});
});
@@ -176,40 +176,33 @@ describe("validatePayload", () => {
}
});
const sectionWithMedia = (src: string) => ({
const sectionWithBody = (body: string) => ({
...help,
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
sections: [{ ...help.sections[0], body }],
});
it("rejects a javascript: media source", () => {
// The markdown renderer drops raw HTML, so src attributes like this one are
// the only place a script URL could still execute.
expect(() =>
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
).toThrow(BadRequestException);
});
it("accepts an uploaded key, a rooted path and an https URL", () => {
for (const src of [
"support-content/9f1c.png",
"/assets/edr-portal-guide.webm",
"https://cdn.example.com/clip.mp4",
it("stores embedded media as refs, whatever the scheme", () => {
// Media now lives in the markdown, so the API no longer validates its URL
// scheme — the portal's renderer does, by dropping anything that is not
// http(s)/mailto/tel/relative. These all persist fine.
for (const body of [
"![](minio:support-content/9f1c.png)",
"![](/assets/edr-portal-guide.webm)",
"![](https://cdn.example.com/clip.mp4)",
]) {
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
expect(() => validatePayload("HELP", sectionWithBody(body))).not.toThrow();
}
});
it("rejects a media kind that is neither image nor video", () => {
it("rejects a section body past the cap", () => {
expect(() =>
validatePayload("HELP", {
...help,
sections: [
{
...help.sections[0],
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
},
],
}),
validatePayload("HELP", sectionWithBody("x".repeat(20_001))),
).toThrow(BadRequestException);
});
it("rejects an unknown property, which is how a stale payload is caught", () => {
expect(() =>
validatePayload("HELP", { ...help, channels: [] }),
).toThrow(BadRequestException);
});

View File

@@ -51,9 +51,6 @@ const MEDIA_REF = new RegExp(
"g",
);
/** Anything not already a URL or a rooted path is a MinIO object key. */
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
@Injectable()
export class SupportContentService {
constructor(
@@ -127,9 +124,9 @@ export class SupportContentService {
}
/**
* Swaps every stored MinIO reference for a freshly signed URL: attachment
* `src` keys, and `minio:<key>` references embedded in markdown by the
* editor's image button.
* Swaps every `minio:<key>` reference embedded in the copy for a freshly
* signed URL. Media lives inside the markdown, so this one pass covers every
* document.
*
* Each distinct key is signed once per request, and a signing failure
* degrades to MinIO's public URL rather than failing the whole page (see
@@ -140,12 +137,6 @@ export class SupportContentService {
): Promise<PortalContentBundle> {
const keys = new Set<string>();
for (const section of bundle.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) keys.add(item.src);
}
}
const collect = (value: unknown): void => {
if (typeof value === "string") {
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
@@ -183,15 +174,7 @@ export class SupportContentService {
return value;
};
const resolved = rewrite(bundle) as PortalContentBundle;
for (const section of resolved.help.sections ?? []) {
for (const item of section.media ?? []) {
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
}
}
return resolved;
return rewrite(bundle) as PortalContentBundle;
}
/** Admin list — metadata only, no payloads. */
@@ -339,13 +322,7 @@ function withGeneratedIds(
switch (slug) {
case "HELP": {
const help = payload as PortalHelpContent;
return {
...help,
sections: help.sections.map((section) => ({
...withId(section),
media: (section.media ?? []).map(withId),
})),
};
return { ...help, sections: help.sections.map(withId) };
}
case "FAQ": {
const faq = payload as PortalFaqContent;