mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
fix: the content document
This commit is contained in:
@@ -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 ------------------------------- */
|
||||
|
||||
@@ -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  below.",
|
||||
media: [
|
||||
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
|
||||
],
|
||||
body:
|
||||
"See  and " +
|
||||
".",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user