@glw907/cairn-cms 0.14.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/crypto.d.ts +8 -2
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +12 -2
- package/dist/auth/store.d.ts +2 -0
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +17 -5
- package/dist/components/EditPage.svelte +4 -6
- package/dist/components/EditPage.svelte.d.ts +1 -1
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +11 -9
- package/dist/delivery/feeds.d.ts +1 -1
- package/dist/delivery/feeds.d.ts.map +1 -1
- package/dist/delivery/feeds.js +31 -16
- package/dist/delivery/site-indexes.d.ts.map +1 -1
- package/dist/delivery/site-indexes.js +9 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +14 -0
- package/dist/github/signing.d.ts +12 -0
- package/dist/github/signing.d.ts.map +1 -1
- package/dist/github/signing.js +22 -0
- package/dist/render/pipeline.d.ts +10 -0
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +15 -1
- package/dist/render/sanitize-schema.d.ts +20 -0
- package/dist/render/sanitize-schema.d.ts.map +1 -0
- package/dist/render/sanitize-schema.js +48 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -1
- package/dist/sveltekit/auth-routes.js +29 -11
- package/dist/sveltekit/content-routes.js +2 -2
- package/dist/sveltekit/guard.d.ts +1 -1
- package/dist/sveltekit/guard.d.ts.map +1 -1
- package/dist/sveltekit/guard.js +25 -10
- package/dist/sveltekit/nav-routes.js +2 -2
- package/dist/sveltekit/public-routes.js +1 -1
- package/dist/sveltekit/types.d.ts +6 -0
- package/dist/sveltekit/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/lib/auth/crypto.ts +14 -2
- package/src/lib/auth/store.ts +18 -5
- package/src/lib/components/EditPage.svelte +4 -6
- package/src/lib/delivery/content-index.ts +12 -9
- package/src/lib/delivery/feeds.ts +34 -19
- package/src/lib/delivery/site-indexes.ts +13 -1
- package/src/lib/env.ts +13 -0
- package/src/lib/github/signing.ts +32 -0
- package/src/lib/render/pipeline.ts +25 -1
- package/src/lib/render/sanitize-schema.ts +57 -0
- package/src/lib/sveltekit/auth-routes.ts +30 -11
- package/src/lib/sveltekit/content-routes.ts +2 -2
- package/src/lib/sveltekit/guard.ts +25 -10
- package/src/lib/sveltekit/nav-routes.ts +2 -2
- package/src/lib/sveltekit/public-routes.ts +1 -1
- package/src/lib/sveltekit/types.ts +5 -1
- package/dist/render/sanitize.d.ts +0 -8
- package/dist/render/sanitize.d.ts.map +0 -1
- package/dist/render/sanitize.js +0 -26
- package/src/lib/render/sanitize.ts +0 -27
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defaultSchema, type Schema } from 'hast-util-sanitize';
|
|
2
|
+
import type { Root, Element } from 'hast';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
import { dataAttrProp, type ComponentRegistry } from './registry.js';
|
|
5
|
+
|
|
6
|
+
// The fixed directive markers the stamp writes and the dispatch reads. They are inert data
|
|
7
|
+
// attributes, never a script vector, and must survive the floor so the dispatch still runs.
|
|
8
|
+
const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
|
|
12
|
+
* GitHub-lineage allowlist that strips scripts, inline event handlers, and javascript:/data: URLs,
|
|
13
|
+
* then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
|
|
14
|
+
* dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
|
|
15
|
+
* the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
|
|
16
|
+
* on anchors are admitted. A site extends the result through `extend`, always starting from this
|
|
17
|
+
* safe base, so it can add to the allowlist but not weaken the core strip.
|
|
18
|
+
*/
|
|
19
|
+
export function buildSanitizeSchema(
|
|
20
|
+
registry: ComponentRegistry,
|
|
21
|
+
extend?: (defaults: Schema) => Schema,
|
|
22
|
+
): Schema {
|
|
23
|
+
const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
|
|
24
|
+
const markers = [...FIXED_MARKERS, ...attrMarkers];
|
|
25
|
+
const attributes = defaultSchema.attributes ?? {};
|
|
26
|
+
// defaultSchema's `a` entry carries a className tuple (`['className', 'data-footnote-backref']`)
|
|
27
|
+
// that restricts a link's class to that one value. A per-tag tuple wins over a bare `*` entry, so
|
|
28
|
+
// it would strip an author's link class. Drop that tuple before admitting a free-form `className`.
|
|
29
|
+
const anchorAttrs = (attributes.a ?? []).filter(
|
|
30
|
+
(entry) => !(Array.isArray(entry) && entry[0] === 'className'),
|
|
31
|
+
);
|
|
32
|
+
const schema: Schema = {
|
|
33
|
+
...defaultSchema,
|
|
34
|
+
tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
|
|
35
|
+
attributes: {
|
|
36
|
+
...attributes,
|
|
37
|
+
'*': [...(attributes['*'] ?? []), 'className', ...markers],
|
|
38
|
+
a: [...anchorAttrs, 'className', 'target', 'rel'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
return extend ? extend(schema) : schema;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
|
|
46
|
+
* hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
|
|
47
|
+
* DOMPurify preview pass enforced, now on the delivered output as well.
|
|
48
|
+
*/
|
|
49
|
+
export function rehypeAnchorRel() {
|
|
50
|
+
return (tree: Root) => {
|
|
51
|
+
visit(tree, 'element', (node: Element) => {
|
|
52
|
+
if (node.tagName === 'a' && node.properties?.target === '_blank') {
|
|
53
|
+
node.properties.rel = 'noopener noreferrer';
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
hashToken,
|
|
10
10
|
TOKEN_TTL_MS,
|
|
11
11
|
SESSION_TTL_MS,
|
|
12
|
-
|
|
12
|
+
SEND_COOLDOWN_MS,
|
|
13
|
+
sessionCookieName,
|
|
13
14
|
} from '../auth/crypto.js';
|
|
14
|
-
import { findEditor, issueToken, consumeToken, createSession, deleteSession } from '../auth/store.js';
|
|
15
|
+
import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
|
|
15
16
|
import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
|
|
16
17
|
import type { RequestContext } from './types.js';
|
|
17
18
|
|
|
@@ -37,11 +38,27 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
|
37
38
|
|
|
38
39
|
const editor = email ? await findEditor(db, email) : null;
|
|
39
40
|
if (editor) {
|
|
40
|
-
const token = generateToken();
|
|
41
41
|
const now = Date.now();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// Per-email cooldown: skip the reissue and send when a token for this email was issued within
|
|
43
|
+
// the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
|
|
44
|
+
// the non-leak property holds.
|
|
45
|
+
if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
|
|
46
|
+
const token = generateToken();
|
|
47
|
+
await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
|
|
48
|
+
const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
|
|
49
|
+
// The token row is the security-critical write the email depends on, so it is awaited. The
|
|
50
|
+
// send is a post-response side effect, handed to waitUntil so a slow email provider does not
|
|
51
|
+
// hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
|
|
52
|
+
// failure is logged so observability survives a backgrounded send.
|
|
53
|
+
const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
|
|
54
|
+
(err) => console.error('cairn: magic-link send failed', err),
|
|
55
|
+
);
|
|
56
|
+
// adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
|
|
57
|
+
// deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
|
|
58
|
+
const ctx = event.platform?.ctx ?? event.platform?.context;
|
|
59
|
+
if (ctx?.waitUntil) ctx.waitUntil(sending);
|
|
60
|
+
else await sending;
|
|
61
|
+
}
|
|
45
62
|
}
|
|
46
63
|
return { sent: true };
|
|
47
64
|
}
|
|
@@ -83,11 +100,12 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
|
83
100
|
|
|
84
101
|
const id = generateSessionId();
|
|
85
102
|
await createSession(db, id, email, now + SESSION_TTL_MS, now);
|
|
86
|
-
event.
|
|
103
|
+
const secure = event.url.protocol === 'https:';
|
|
104
|
+
event.cookies.set(sessionCookieName(secure), id, {
|
|
87
105
|
path: '/',
|
|
88
106
|
httpOnly: true,
|
|
89
|
-
//
|
|
90
|
-
secure
|
|
107
|
+
// __Host- needs Secure unconditionally on https; local http dev drops the prefix and Secure.
|
|
108
|
+
secure,
|
|
91
109
|
sameSite: 'lax',
|
|
92
110
|
maxAge: Math.floor(SESSION_TTL_MS / 1000),
|
|
93
111
|
});
|
|
@@ -97,9 +115,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
|
|
|
97
115
|
/** POST /admin/auth/logout. Deletes the session row and clears the cookie. */
|
|
98
116
|
async function logoutAction(event: RequestContext): Promise<never> {
|
|
99
117
|
const db = requireDb(event.platform?.env ?? {});
|
|
100
|
-
const
|
|
118
|
+
const name = sessionCookieName(event.url.protocol === 'https:');
|
|
119
|
+
const id = event.cookies.get(name);
|
|
101
120
|
if (id) await deleteSession(db, id);
|
|
102
|
-
event.cookies.delete(
|
|
121
|
+
event.cookies.delete(name, { path: '/' });
|
|
103
122
|
throw redirect(303, '/admin/login');
|
|
104
123
|
}
|
|
105
124
|
|
|
@@ -8,7 +8,7 @@ import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown }
|
|
|
8
8
|
import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
|
|
9
9
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
10
10
|
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
11
|
-
import {
|
|
11
|
+
import { cachedInstallationToken } from '../github/signing.js';
|
|
12
12
|
import { CommitConflictError } from '../github/types.js';
|
|
13
13
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
14
14
|
import type { Editor, Role } from '../auth/types.js';
|
|
@@ -96,7 +96,7 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
|
|
|
96
96
|
|
|
97
97
|
export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
|
|
98
98
|
const mintToken =
|
|
99
|
-
deps.mintToken ?? ((env: GithubKeyEnv) =>
|
|
99
|
+
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
100
100
|
|
|
101
101
|
/** Layout load for every admin page: the nav, the user, and the active path. */
|
|
102
102
|
function layoutLoad(event: ContentEvent): LayoutData {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stays free of a site's App.* ambient types.
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
|
-
import {
|
|
6
|
+
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
7
|
import type { Editor } from '../auth/types.js';
|
|
8
8
|
import type { HandleInput, RequestContext } from './types.js';
|
|
9
9
|
|
|
@@ -16,19 +16,34 @@ function isAdminPath(pathname: string): boolean {
|
|
|
16
16
|
return pathname === '/admin' || pathname.startsWith('/admin/');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
21
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
22
|
+
*/
|
|
23
|
+
function applySecurityHeaders(headers: Headers): void {
|
|
24
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
25
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
26
|
+
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
27
|
+
headers.set('Referrer-Policy', 'no-referrer');
|
|
28
|
+
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
29
|
+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
20
33
|
export function createAuthGuard() {
|
|
21
34
|
return async function handle({ event, resolve }: HandleInput): Promise<Response> {
|
|
22
35
|
const { pathname } = event.url;
|
|
23
|
-
if (!isAdminPath(pathname)
|
|
24
|
-
|
|
36
|
+
if (!isAdminPath(pathname)) return resolve(event);
|
|
37
|
+
if (!isPublicAdminPath(pathname)) {
|
|
38
|
+
const env = event.platform?.env ?? {};
|
|
39
|
+
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
40
|
+
const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
|
|
41
|
+
if (!editor) throw redirect(303, '/admin/login');
|
|
42
|
+
event.locals.editor = editor;
|
|
25
43
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!editor) throw redirect(303, '/admin/login');
|
|
30
|
-
event.locals.editor = editor;
|
|
31
|
-
return resolve(event);
|
|
44
|
+
const response = await resolve(event);
|
|
45
|
+
applySecurityHeaders(response.headers);
|
|
46
|
+
return response;
|
|
32
47
|
};
|
|
33
48
|
}
|
|
34
49
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and commit paths are unit-testable against a fetch double with an injected token.
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
6
|
-
import {
|
|
6
|
+
import { cachedInstallationToken } from '../github/signing.js';
|
|
7
7
|
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
8
8
|
import { CommitConflictError } from '../github/types.js';
|
|
9
9
|
import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
|
|
@@ -45,7 +45,7 @@ function isConflict(err: unknown): boolean {
|
|
|
45
45
|
|
|
46
46
|
export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
|
|
47
47
|
const mintToken =
|
|
48
|
-
deps.mintToken ?? ((env: GithubKeyEnv) =>
|
|
48
|
+
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
49
49
|
|
|
50
50
|
/** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
|
|
51
51
|
async function pageOptions(token: string): Promise<NavPageOption[]> {
|
|
@@ -83,7 +83,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
|
83
83
|
...(image ? { image } : {}),
|
|
84
84
|
...(fields.robots ? { robots: fields.robots } : {}),
|
|
85
85
|
...(fields.author ? { author: fields.author } : {}),
|
|
86
|
-
feeds,
|
|
86
|
+
...(entry.date ? { feeds } : {}),
|
|
87
87
|
});
|
|
88
88
|
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|
|
89
89
|
}
|
|
@@ -21,7 +21,11 @@ export interface RequestContext {
|
|
|
21
21
|
request: Request;
|
|
22
22
|
cookies: CookieJar;
|
|
23
23
|
locals: { editor?: Editor | null };
|
|
24
|
-
platform?: {
|
|
24
|
+
platform?: {
|
|
25
|
+
env?: AuthEnv;
|
|
26
|
+
ctx?: { waitUntil(promise: Promise<unknown>): void };
|
|
27
|
+
context?: { waitUntil(promise: Promise<unknown>): void };
|
|
28
|
+
};
|
|
25
29
|
// Required so a site cannot silently drop the confirm page's Referrer-Policy header
|
|
26
30
|
// (spec 7.1). A real SvelteKit RequestEvent always supplies it.
|
|
27
31
|
setHeaders(headers: Record<string, string>): void;
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
|
|
3
|
-
* handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
|
|
4
|
-
* Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
|
|
5
|
-
* reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
|
|
6
|
-
*/
|
|
7
|
-
export declare function sanitizePreviewHtml(html: string): Promise<string>;
|
|
8
|
-
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/lib/render/sanitize.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAavE"}
|
package/dist/render/sanitize.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
|
|
2
|
-
// so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
|
|
3
|
-
// DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
|
|
4
|
-
// loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
|
|
5
|
-
// server import of this file pulls in nothing.
|
|
6
|
-
let purify = null;
|
|
7
|
-
/**
|
|
8
|
-
* Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
|
|
9
|
-
* handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
|
|
10
|
-
* Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
|
|
11
|
-
* reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
|
|
12
|
-
*/
|
|
13
|
-
export async function sanitizePreviewHtml(html) {
|
|
14
|
-
if (!purify) {
|
|
15
|
-
const mod = await import('dompurify');
|
|
16
|
-
purify = mod.default;
|
|
17
|
-
purify.addHook('afterSanitizeAttributes', (node) => {
|
|
18
|
-
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
|
19
|
-
node.setAttribute('rel', 'noopener noreferrer');
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
// ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
|
|
24
|
-
// can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
|
|
25
|
-
return purify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
26
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
|
|
2
|
-
// so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
|
|
3
|
-
// DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
|
|
4
|
-
// loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
|
|
5
|
-
// server import of this file pulls in nothing.
|
|
6
|
-
let purify: { sanitize(html: string, config?: Record<string, unknown>): string; addHook(event: string, cb: (node: Element) => void): void } | null = null;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
|
|
10
|
-
* handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
|
|
11
|
-
* Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
|
|
12
|
-
* reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
|
|
13
|
-
*/
|
|
14
|
-
export async function sanitizePreviewHtml(html: string): Promise<string> {
|
|
15
|
-
if (!purify) {
|
|
16
|
-
const mod = await import('dompurify');
|
|
17
|
-
purify = mod.default;
|
|
18
|
-
purify.addHook('afterSanitizeAttributes', (node) => {
|
|
19
|
-
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
|
20
|
-
node.setAttribute('rel', 'noopener noreferrer');
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
// ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
|
|
25
|
-
// can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
|
|
26
|
-
return purify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
27
|
-
}
|