Skip to content
Open
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
31 changes: 28 additions & 3 deletions apps/main-processor/__tests__/export.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { mkdtemp, writeFile, rm, realpath } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { buildDocument } from '../src/export/buildDocument';
import { sanitizeCss } from '../src/export/sanitizeCss';
import { getImage } from '../src/export/getImage';
import { inlineImages } from '../src/export/inlineImage';
import { allowedFolderRoots } from '../src/utils/constants/ipc-validation';

describe('build html document', () => {
it('wraps content in a valid HTML5 document shell', () => {
Expand Down Expand Up @@ -37,11 +38,20 @@ describe('sanitise css for export ', () => {

it('removes unsafe css patterns', () => {
const css = sanitizeCss(
'body { behavior: url(test.htc); background: url(javascript:alert(1)); }'
'body { behavior: url(test.htc); background: url(javascript:alert(1)); } ' +
'@import "http://example.com/style.css"; ' +
'@import url("https://example.com/no-semicolon")' +
'div { background: url(https://example.com/bg.png); } ' +
'p { background: url(//example.com/bg.png); } ' +
'span { background: url(http://example.com/bg.png); }'
);

expect(css).not.toContain('behavior');
expect(css).not.toContain('javascript:');
expect(css).not.toContain('@import');
expect(css).not.toContain('https://');
expect(css).not.toContain('http://');
expect(css).not.toContain('//example.com');
});
});

Expand All @@ -58,11 +68,26 @@ describe('get image of mime type', () => {
describe('inline images for HTML export', () => {
it('keeps local images as base 64 data URIs', async () => {
const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-'));
const resolvedDir = await realpath(dir);
allowedFolderRoots.add(resolvedDir);
try {
const imagePath = join(dir, 'image.png');
const imagePath = join(resolvedDir, 'image.png');
await writeFile(imagePath, Buffer.from([137, 80, 78, 71]));
const html = await inlineImages(`<img src="${imagePath}"/>`);
expect(html).toContain('src="data:image/png;base64,');
} finally {
allowedFolderRoots.delete(resolvedDir);
await rm(dir, { recursive: true, force: true });
}
});

it('does not inline images outside approved folders', async () => {
const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-deny-'));
try {
const imagePath = join(dir, 'private.png');
await writeFile(imagePath, Buffer.from([137, 80, 78, 71]));
const html = await inlineImages(`<img src="${imagePath}"/>`);
expect(html).toBe(`<img src="${imagePath}"/>`);
} finally {
await rm(dir, { recursive: true, force: true });
}
Expand Down
13 changes: 13 additions & 0 deletions apps/main-processor/__tests__/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { realpath, stat } from 'node:fs/promises';
import { validatePath, validateSender } from '../src/utils/constants/ipc-validation';
import { resolveMarkdownFilePath } from '../src/utils/helper/ipc-path-resolver';
import { pathToFileURL } from 'node:url';
import { PATHS } from '../src/utils/constants/path-constants';

vi.mock('node:fs/promises', () => ({
realpath: vi.fn(),
Expand Down Expand Up @@ -37,6 +39,17 @@ describe('ipc - validation test', () => {
expect(validateSender(event)).toBe(false);
});

it('returns true for valid file:// url matching RENDERER_HTML', () => {
const validUrl = pathToFileURL(PATHS.RENDERER_HTML).href;
const event = mockEvent(validUrl);
expect(validateSender(event)).toBe(true);
});

it('returns false for invalid file:// url', () => {
const event = mockEvent('file:///etc/passwd');
expect(validateSender(event)).toBe(false);
});

//test 3:- to check correct file path
it('returns true for a valid file path string', () => {
expect(validatePath('C:\\Users\\file.md')).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/main-processor/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('settings validation', () => {
customCss: '.markdown-body { color: red; }',
});
expect(() => validateSettings({ customCss: 42 as never })).toThrow('Invalid customCss');
expect(() => validateSettings({ customCss: 'a'.repeat(10001) })).toThrow('Invalid customCss');
});

it('validates zoom range', () => {
Expand Down
31 changes: 28 additions & 3 deletions apps/main-processor/src/export/inlineImage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { readFile } from 'node:fs/promises';
import { readFile, realpath } from 'node:fs/promises';
import { EXPORT_CONST } from '../utils/constants/export-constants';
import { normaliseImagePath } from '../utils/helper/path-helper';
import { getImage } from './getImage';
import {
allowedFolderRoots,
allowedMarkdownFiles,
isPathInside,
} from '../utils/constants/ipc-validation';
import { dirname } from 'node:path';

export async function inlineImages(html: string): Promise<string> {
const matches = [...html.matchAll(EXPORT_CONST.EXPORT_IMAGE_SRC_REGEX)];
Expand All @@ -15,8 +21,27 @@ export async function inlineImages(html: string): Promise<string> {

try {
const imagePath = normaliseImagePath(src);
const data = await readFile(imagePath);
const mimeType = getImage(imagePath);
const resolveImagePath = await realpath(imagePath);
let isAllowed = false;
for (const root of allowedFolderRoots) {
if (isPathInside(resolveImagePath, root)) {
isAllowed = true;
break;
}
}
if (!isAllowed) {
for (const file of allowedMarkdownFiles) {
const parentDir = dirname(file);
if (isPathInside(resolveImagePath, parentDir)) {
isAllowed = true;
break;
}
}
}

if (!isAllowed) continue;
const data = await readFile(resolveImagePath);
const mimeType = getImage(resolveImagePath);
const base64 = data.toString('base64');

output = output.replace(fullMatch, `src="data:${mimeType};base64,${base64}"`);
Expand Down
12 changes: 3 additions & 9 deletions apps/main-processor/src/folder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdir, realpath } from 'node:fs/promises';
import { readdir } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { FileType } from '@package/shared-types';
import { isMarkdownFile } from './utils/helper/path-helper';
Expand All @@ -10,21 +10,15 @@ export async function getFolder(
currentDepth = 0,
seenPaths = new Set<string>()
): Promise<FileType> {
let realFolderPath: string;
try {
realFolderPath = await realpath(folderPath);
} catch {
realFolderPath = folderPath;
}
if (currentDepth >= maxDepth || seenPaths.has(realFolderPath)) {
if (currentDepth >= maxDepth || seenPaths.has(folderPath)) {
return {
name: basename(folderPath),
path: folderPath,
isDir: true,
children: [],
};
}
seenPaths.add(realFolderPath);
seenPaths.add(folderPath);
const entries = (await readdir(folderPath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
);
Expand Down
2 changes: 2 additions & 0 deletions apps/main-processor/src/utils/constants/export-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export const EXPORT_CONST = {
/-moz-binding\s*:/gi,
/expression\s*\(/gi,
/javascript\s*:/gi,
/@import\b[^;]*(?:;|$)/gi,
/url\s*\(\s*['"]?\s*(?:https?:|\/\/)[^)]*\)/gi,
],
};
8 changes: 7 additions & 1 deletion apps/main-processor/src/utils/constants/ipc-validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import path from 'path';
import { fileURLToPath } from 'node:url';
import { IpcMainInvokeEvent } from 'electron';
import { PATHS } from './path-constants';

// production and dev urls
export const ALLOWED_MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
Expand All @@ -16,7 +18,11 @@ export function validateSender(event: IpcMainInvokeEvent): boolean {
const parsedUrl = new URL(url);

if (parsedUrl.protocol === 'file:') {
return true;
parsedUrl.hash = '';
parsedUrl.search = '';
const filePath = path.resolve(fileURLToPath(parsedUrl.href));
const expectedPath = path.resolve(PATHS.RENDERER_HTML);
return filePath === expectedPath;
}

return parsedUrl.protocol === 'http:' && parsedUrl.hostname === 'localhost';
Expand Down
4 changes: 2 additions & 2 deletions apps/main-processor/src/utils/helper/setting-helper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app } from 'electron';
import path from 'path';
import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
import { mkdir, open, rename, unlink } from 'node:fs/promises';
import { AppSettings } from '@package/shared-types';
import { THEMES } from '@package/shared-constants';
import { SETTINGS_KEYS, READING_WIDTHS } from '../constants/setting-constants';
Expand Down Expand Up @@ -44,7 +44,7 @@ export function validateSettings(partial: Partial<AppSettings>): Partial<AppSett
validated.lineNumbers = value;
}
if (key === 'customCss') {
if (typeof value !== 'string') throw new Error('Invalid customCss');
if (typeof value !== 'string' || value.length > 10000) throw new Error('Invalid customCss');
validated.customCss = value;
}
if (key === 'zoom') {
Expand Down
54 changes: 54 additions & 0 deletions apps/renderer/__tests__/config/marked.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMarkdown } from '../../src/config/marked';
import { heading } from '../../src/utils/helpers/heading-helper';

vi.mock('../../src/utils/helpers/heading-helper', () => ({
escapeHtml: (str: string) => str,
heading: vi.fn().mockImplementation((props, registry) => {
const count = registry.get(props.text) || 0;
return `<h${props.depth}>${props.text}-${count}</h${props.depth}>`;
}),
}));

describe('get markdown instance and registry tests', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should return the same instance on multiple calls', () => {
const registry1 = new Map<string, number>();
const registry2 = new Map<string, number>();
const firstInstance = getMarkdown(registry1);
const secondInstance = getMarkdown(registry2);
expect(firstInstance).toBe(secondInstance);
});

it('should pass the correct registry data down to the heading renderer', async () => {
const registry = new Map<string, number>([['Hello', 5]]);
const instance = getMarkdown(registry);
const result = await instance.parse('# Hello');
expect(heading).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Hello', depth: 1 }),
registry
);
expect(result).toContain('Hello-5');
});

it('should switch to a new registry map on a subsequent call', async () => {
const firstRegistry = new Map<string, number>([['Title', 1]]);
const secondRegistry = new Map<string, number>([['Title', 99]]);
const instance1 = getMarkdown(firstRegistry);
await instance1.parse('# Title');
const instance2 = getMarkdown(secondRegistry);
const result = await instance2.parse('# Title');
expect(result).toContain('Title-99');
expect(result).not.toContain('Title-1');
});

it('should handle an empty registry map without throwing error', async () => {
const emptyRegistry = new Map<string, number>();
const instance = getMarkdown(emptyRegistry);
const parseAction = () => instance.parse('# Empty Test');
await expect(parseAction()).resolves.not.toThrow();
});
});
8 changes: 8 additions & 0 deletions apps/renderer/__tests__/renderer/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,12 @@ describe('renderMarkdown', () => {
`);
expect(html).toContain('onerror');
});

it('it should reset heading counters between independent render markdown calls', async () => {
const html1 = await renderMarkdown('# Hello World');
expect(html1).toContain('<h1 id="hello-world">Hello World</h1>');
const html2 = await renderMarkdown('# Hello World');
expect(html2).toContain('<h1 id="hello-world">Hello World</h1>');
expect(html2).not.toContain('id="hello-world-1"');
});
});
5 changes: 3 additions & 2 deletions apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ useShortcuts({
)}

{activeTab && !isLoading && !error && (
<ErrorBoundary>
<div className="flex flex-1 overflow-hidden relative">
{!focusMode && (
<FileBrowser
Expand Down Expand Up @@ -212,11 +213,11 @@ useShortcuts({
className="flex-1 overflow-y-auto"
onScroll={scroll}
>
<ErrorBoundary>

<Reader html={activeTab.html} getHiglightedHtml={getHiglightedHtml} />
</ErrorBoundary>
</main>
</div>
</ErrorBoundary>
)}

<Toast message="File updated" show={showToast} onDone={() => setShowToast(false)} />
Expand Down
13 changes: 9 additions & 4 deletions apps/renderer/src/config/marked.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import { shikiHighlighter } from '../renderer/shiki';
import { THEMES } from '@package/shared-constants';
import { escapeHtml, heading } from '../utils/helpers/heading-helper';
import { MARKDOWN_LANGUAGES } from '../utils/constants/markdown-constants';
import { DEFAULT_THEME } from '../utils/constants/theme-constants';

let instance: Marked | null = null;
let currentRegistry: Map<string, number>;

export function getMarkdown(registry: Map<string, number>): Marked {
const instance = new Marked();
currentRegistry = registry;
if (instance) return instance;
instance = new Marked();

// configure marked with GFM options
instance.use({
gfm: true,
breaks: false,
renderer: {
heading(props) {
return heading(props, registry);
return heading(props, currentRegistry);
},
},
});
Expand All @@ -29,7 +34,7 @@ export function getMarkdown(registry: Map<string, number>): Marked {
return escapeHtml(code);
}
const highlighter = await shikiHighlighter();
const theme = THEMES[1];
const theme = DEFAULT_THEME;
try {
return highlighter.codeToHtml(code, {
lang: language,
Expand Down
7 changes: 3 additions & 4 deletions apps/renderer/src/hooks/useFilePersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export function useFilePersistence({
window.clearTimeout(debounceTimer.current);
}
debounceTimer.current = window.setTimeout(async () => {
if (!debounceTimer.current) return;
const currentScroll = contentRef.current?.scrollTop ?? 0;
const result = await loadFile(activeTab.filePath);
if (!result || !isMounted.current) return;
Expand Down Expand Up @@ -64,14 +63,14 @@ export function useFilePersistence({
}

scrollTimer.current = window.setTimeout(() => {
if (!scrollTimer.current || !contentRef.current) return;
saveScrollPos(activeTab.filePath, contentRef.current!.scrollTop);
if (!isMounted.current || !contentRef.current) return;
saveScrollPos(activeTab.filePath, contentRef.current.scrollTop);

dispatch({
type: 'UPDATE_TAB_STATE',
payload: {
tabId: activeTab.id,
scrollTop: contentRef.current?.scrollTop ?? 0,
scrollTop: contentRef.current.scrollTop,
},
});
}, 100);
Expand Down
7 changes: 0 additions & 7 deletions apps/renderer/src/types/component-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,6 @@ export interface ThemeContextType {
setTheme: (theme: Theme) => void;
}

export type BuiltThemeType =
| 'github-light'
| 'github-dark'
| 'notion'
| 'nord'
| 'minimal'
| 'dracula';
export interface HeadingProps {
text: string;
depth: number;
Expand Down
2 changes: 2 additions & 0 deletions apps/renderer/src/utils/constants/theme-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export const APPTHEMES = [
'minimal',
'dracula',
] as const;

export const DEFAULT_THEME = 'github-dark';
Loading