mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-11 17:23:37 +01:00
feat(markdown): add LaTeX math support
This commit is contained in:
1 parent
f7a8a60440
commit
9c796a873c
11 files changed
+1592
-1464
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { MarkdownEditor } from '@/js/utils/markdown-editor';
|
||||
import { useMarkdownPlugins } from '@/js/utils/markdown-plugins';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
function preview(markdown?: string): HTMLElement {
|
||||
new MarkdownEditor(
|
||||
container,
|
||||
markdown === undefined ? {} : { initialContent: markdown }
|
||||
);
|
||||
return container.querySelector('#mdPreview') as HTMLElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
describe('Markdown to PDF rendering', () => {
|
||||
it('renders every feature of the default sample', () => {
|
||||
const root = preview();
|
||||
for (const selector of [
|
||||
'sub',
|
||||
'sup',
|
||||
'mark',
|
||||
'ins',
|
||||
'abbr',
|
||||
'dl dd',
|
||||
'.footnotes',
|
||||
'input[type="checkbox"]',
|
||||
'.katex',
|
||||
'.katex-display',
|
||||
'pre code.hljs, pre code .hljs-keyword',
|
||||
]) {
|
||||
expect(root.querySelector(selector), selector).not.toBeNull();
|
||||
}
|
||||
const tocLinks = root.querySelectorAll('.table-of-contents a');
|
||||
expect(tocLinks.length).toBeGreaterThan(5);
|
||||
for (const link of tocLinks) {
|
||||
const id = decodeURIComponent(link.getAttribute('href')!.slice(1));
|
||||
expect(document.getElementById(id), id).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders every math delimiter and leaves prices and code alone', () => {
|
||||
const root = preview(
|
||||
[
|
||||
'Dollar $a*b*c$ and paren \\(x^2\\) and escaped \\\\(y\\\\).',
|
||||
'',
|
||||
'$$\nx\\,y\n$$',
|
||||
'',
|
||||
'\\[\n\\frac{a}{b}\n\\]',
|
||||
'',
|
||||
'Mid \\[z\\] and \\\\[w\\\\] here.',
|
||||
'',
|
||||
'```math\n\\int x\n```',
|
||||
'',
|
||||
'Price $5 and $10, escaped \\$3, code `$q$`.',
|
||||
].join('\n')
|
||||
);
|
||||
expect(root.querySelectorAll('.katex').length).toBe(8);
|
||||
expect(root.querySelectorAll('.katex-display').length).toBe(5);
|
||||
expect(root.querySelector('em')).toBeNull();
|
||||
expect(root.textContent).toContain('Price $5 and $10, escaped $3');
|
||||
expect(root.querySelector('code')?.textContent).toBe('$q$');
|
||||
});
|
||||
|
||||
it('links accented and duplicate headings from the table of contents', () => {
|
||||
const root = preview('[[toc]]\n\n## Café & Crème\n\n## Same\n\n## Same');
|
||||
const hrefs = Array.from(
|
||||
root.querySelectorAll('.table-of-contents a'),
|
||||
(a) => decodeURIComponent(a.getAttribute('href')!.slice(1))
|
||||
);
|
||||
expect(hrefs).toHaveLength(3);
|
||||
expect(new Set(hrefs).size).toBe(3);
|
||||
for (const id of hrefs)
|
||||
expect(document.getElementById(id), id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('only treats a line holding nothing but the marker as a TOC', () => {
|
||||
const root = preview('[TOC] is short for table of contents.');
|
||||
expect(root.querySelector('.table-of-contents')).toBeNull();
|
||||
expect(root.textContent).toContain('is short for table of contents.');
|
||||
});
|
||||
|
||||
it('still links bare domains', () => {
|
||||
const root = preview('Visit www.example.com or example.com today');
|
||||
expect(root.querySelectorAll('a[href^="http"]').length).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps KaTeX from creating links, images or raw HTML', () => {
|
||||
const root = preview(
|
||||
[
|
||||
'$\\href{javascript:alert(1)}{x}$',
|
||||
'$\\url{javascript:alert(2)}$',
|
||||
'$\\includegraphics{https://example.com/x.png}$',
|
||||
'$\\htmlClass{x}{y}\\htmlStyle{position:fixed}{z}\\htmlId{app}{w}$',
|
||||
'\\[\\text{<img src=x onerror=alert(3)>}\\]',
|
||||
'$\\def\\a{\\a\\a}\\a$',
|
||||
].join('\n\n')
|
||||
);
|
||||
expect(root.querySelector('a, img, script, [onerror], #app')).toBeNull();
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
for (const attr of el.attributes) {
|
||||
expect(attr.value, attr.name).not.toMatch(/javascript:|fixed/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('strips script and handlers from raw HTML next to math', () => {
|
||||
const root = preview(
|
||||
'<img src=x onerror=alert(1)> $x$ <svg onload=alert(2)></svg>\n\n<a href="javascript:alert(3)">x</a>'
|
||||
);
|
||||
expect(root.querySelector('[onerror], [onload], script')).toBeNull();
|
||||
expect(root.innerHTML).not.toMatch(/javascript:/i);
|
||||
});
|
||||
|
||||
it('removes elements that can restyle or cover the rest of the page', () => {
|
||||
const root = preview(
|
||||
[
|
||||
'x <style>body{display:none}</style>',
|
||||
'<svg><style>body{display:none}</style></svg>',
|
||||
'<form action="https://example.com"><input type="password"></form>',
|
||||
'<button popovertarget="p">x</button><div popover id="p">x</div>',
|
||||
'<dialog open>x</dialog>',
|
||||
'- [x] done',
|
||||
].join('\n\n')
|
||||
);
|
||||
expect(root.querySelector('style, form, dialog, [popover]')).toBeNull();
|
||||
expect(root.querySelector('input[type="checkbox"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps editor controls ahead of document ids with the same name', () => {
|
||||
preview('<div id="mdSettingsModal"></div><input id="mdOptHtml">');
|
||||
const modal = document.getElementById('mdSettingsModal');
|
||||
expect(modal?.classList.contains('md-editor-modal-overlay')).toBe(true);
|
||||
expect(
|
||||
document.getElementById('mdOptHtml')?.closest('#mdPreview')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Markdown renderer', () => {
|
||||
it('renders the shared extensions and leaves emoji and task markers as text', () => {
|
||||
const html = useMarkdownPlugins(new MarkdownIt({ linkify: true })).render(
|
||||
'[[toc]]\n\n## Notes\n\nH~2~O x^2^ ==m== ++i++ :smile:\n\n- [x] done\n\nTerm\n: def\n\nx[^1]\n\n[^1]: n\n\nsee example.com'
|
||||
);
|
||||
for (const tag of [
|
||||
'<sub>',
|
||||
'<sup>',
|
||||
'<mark>',
|
||||
'<ins>',
|
||||
'<dd>',
|
||||
'footnote',
|
||||
'table-of-contents',
|
||||
'href="http://example.com"',
|
||||
]) {
|
||||
expect(html, tag).toContain(tag);
|
||||
}
|
||||
expect(html).toContain(':smile:');
|
||||
expect(html).toContain('[x] done');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import forge from 'node-forge';
|
||||
import { validateSignature } from '../js/logic/validate-signature-pdf';
|
||||
|
||||
type KeyPair = forge.pki.rsa.KeyPair;
|
||||
|
||||
function keys(e = 0x10001): KeyPair {
|
||||
return forge.pki.rsa.generateKeyPair({ bits: 1024, e });
|
||||
}
|
||||
|
||||
function issue(
|
||||
name: string,
|
||||
serial: string,
|
||||
subjectKeys: KeyPair,
|
||||
issuer: { name: string; keys: KeyPair },
|
||||
isCa: boolean
|
||||
): forge.pki.Certificate {
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = subjectKeys.publicKey;
|
||||
cert.serialNumber = serial;
|
||||
cert.setSubject([{ name: 'commonName', value: name }]);
|
||||
cert.setIssuer([{ name: 'commonName', value: issuer.name }]);
|
||||
cert.validity.notBefore = new Date(Date.now() - 86400000);
|
||||
cert.validity.notAfter = new Date(Date.now() + 86400000);
|
||||
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
|
||||
cert.sign(issuer.keys.privateKey, forge.md.sha256.create());
|
||||
return cert;
|
||||
}
|
||||
|
||||
function sign(
|
||||
embedded: forge.pki.Certificate[],
|
||||
namedSigner: forge.pki.Certificate,
|
||||
signingKey: forge.pki.rsa.PrivateKey,
|
||||
trustedCert: forge.pki.Certificate
|
||||
) {
|
||||
const before = new TextEncoder().encode('%PDF-1.4\nsigned A\n');
|
||||
const after = new TextEncoder().encode('\nsigned B\n%%EOF\n');
|
||||
const pdfBytes = new Uint8Array(before.length + after.length);
|
||||
pdfBytes.set(before, 0);
|
||||
pdfBytes.set(after, before.length);
|
||||
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(String.fromCharCode(...pdfBytes));
|
||||
for (const cert of embedded) p7.addCertificate(cert);
|
||||
p7.addSigner({
|
||||
key: signingKey,
|
||||
certificate: namedSigner,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
|
||||
{ type: forge.pki.oids.messageDigest },
|
||||
],
|
||||
});
|
||||
p7.sign({ detached: true });
|
||||
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
|
||||
const contents = Uint8Array.from(der, (c) => c.charCodeAt(0));
|
||||
|
||||
return validateSignature(
|
||||
{
|
||||
index: 0,
|
||||
contents,
|
||||
byteRange: [0, before.length, before.length, after.length],
|
||||
},
|
||||
pdfBytes,
|
||||
trustedCert
|
||||
);
|
||||
}
|
||||
|
||||
function chain(signerExponent = 0x10001, caExponent = 0x10001) {
|
||||
const caKeys = keys(caExponent);
|
||||
const signerKeys = keys(signerExponent);
|
||||
const ca = issue(
|
||||
'Test CA',
|
||||
'01',
|
||||
caKeys,
|
||||
{ name: 'Test CA', keys: caKeys },
|
||||
true
|
||||
);
|
||||
const signer = issue(
|
||||
'Test Signer',
|
||||
'02',
|
||||
signerKeys,
|
||||
{ name: 'Test CA', keys: caKeys },
|
||||
false
|
||||
);
|
||||
return { ca, signer, signerKeys };
|
||||
}
|
||||
|
||||
describe('RSA keys with a low public exponent (GHSA-86w9-cpqp-85rv)', () => {
|
||||
it('still verifies a genuine signature from an e=3 signer key', async () => {
|
||||
const { ca, signer, signerKeys } = chain(3);
|
||||
const result = await sign([signer, ca], signer, signerKeys.privateKey, ca);
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust a chain through an e=3 CA key', async () => {
|
||||
const { ca, signer, signerKeys } = chain(0x10001, 3);
|
||||
const result = await sign([signer, ca], signer, signerKeys.privateKey, ca);
|
||||
expect(result.cryptoVerified).toBe(true);
|
||||
expect(result.isTrusted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signer certificate binding', () => {
|
||||
it('rejects a signature made with another embedded certificate key', async () => {
|
||||
const { ca, signer: victim } = chain();
|
||||
const attackerKeys = keys();
|
||||
const attacker = issue(
|
||||
'Attacker',
|
||||
'03',
|
||||
attackerKeys,
|
||||
{ name: 'Attacker', keys: attackerKeys },
|
||||
false
|
||||
);
|
||||
const result = await sign(
|
||||
[attacker, victim, ca],
|
||||
victim,
|
||||
attackerKeys.privateKey,
|
||||
ca
|
||||
);
|
||||
expect(result.signerName).toBe('Test Signer');
|
||||
expect(result.cryptoVerified).toBe(false);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a signature whose named signer certificate is missing', async () => {
|
||||
const { ca, signer: victim } = chain();
|
||||
const attackerKeys = keys();
|
||||
const attacker = issue(
|
||||
'Attacker',
|
||||
'03',
|
||||
attackerKeys,
|
||||
{ name: 'Attacker', keys: attackerKeys },
|
||||
false
|
||||
);
|
||||
const result = await sign(
|
||||
[attacker, ca],
|
||||
victim,
|
||||
attackerKeys.privateKey,
|
||||
ca
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.isTrusted).toBe(false);
|
||||
expect(result.errorMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import DOMPurify from 'dompurify';
|
||||
import forge from 'node-forge';
|
||||
import { escapeHtml, sanitizeEmailHtml } from '../js/utils/helpers';
|
||||
import { MarkdownEditor } from '../js/utils/markdown-editor';
|
||||
import { validateSignature } from '../js/logic/validate-signature-pdf';
|
||||
import type { ExtractedSignature } from '@/types';
|
||||
|
||||
function renderAsPreviewWould(markdown: string): string {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
});
|
||||
const raw = md.render(markdown);
|
||||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
new MarkdownEditor(container, { initialContent: markdown });
|
||||
const html = container.querySelector('#mdPreview')!.innerHTML;
|
||||
container.remove();
|
||||
return html;
|
||||
}
|
||||
|
||||
function assertNoExecutableContent(html: string) {
|
||||
|
||||
Reference in new issue
Block a user