diff --git a/apps/main-processor/__tests__/export.test.ts b/apps/main-processor/__tests__/export.test.ts
index 6c5ebac..a1d7279 100644
--- a/apps/main-processor/__tests__/export.test.ts
+++ b/apps/main-processor/__tests__/export.test.ts
@@ -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', () => {
@@ -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');
});
});
@@ -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(`
`);
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(`
`);
+ expect(html).toBe(`
`);
} finally {
await rm(dir, { recursive: true, force: true });
}
diff --git a/apps/main-processor/__tests__/ipc.test.ts b/apps/main-processor/__tests__/ipc.test.ts
index 7985fc4..966d41f 100644
--- a/apps/main-processor/__tests__/ipc.test.ts
+++ b/apps/main-processor/__tests__/ipc.test.ts
@@ -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(),
@@ -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);
diff --git a/apps/main-processor/__tests__/settings.test.ts b/apps/main-processor/__tests__/settings.test.ts
index 149afa4..3232315 100644
--- a/apps/main-processor/__tests__/settings.test.ts
+++ b/apps/main-processor/__tests__/settings.test.ts
@@ -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', () => {
diff --git a/apps/main-processor/src/export/inlineImage.ts b/apps/main-processor/src/export/inlineImage.ts
index be5d112..17fd90b 100644
--- a/apps/main-processor/src/export/inlineImage.ts
+++ b/apps/main-processor/src/export/inlineImage.ts
@@ -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 {
const matches = [...html.matchAll(EXPORT_CONST.EXPORT_IMAGE_SRC_REGEX)];
@@ -15,8 +21,27 @@ export async function inlineImages(html: string): Promise {
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}"`);
diff --git a/apps/main-processor/src/folder.ts b/apps/main-processor/src/folder.ts
index 36bf038..be8555a 100644
--- a/apps/main-processor/src/folder.ts
+++ b/apps/main-processor/src/folder.ts
@@ -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';
@@ -10,13 +10,7 @@ export async function getFolder(
currentDepth = 0,
seenPaths = new Set()
): Promise {
- 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,
@@ -24,7 +18,7 @@ export async function getFolder(
children: [],
};
}
- seenPaths.add(realFolderPath);
+ seenPaths.add(folderPath);
const entries = (await readdir(folderPath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
);
diff --git a/apps/main-processor/src/utils/constants/export-constants.ts b/apps/main-processor/src/utils/constants/export-constants.ts
index 6ce9194..1a728f5 100644
--- a/apps/main-processor/src/utils/constants/export-constants.ts
+++ b/apps/main-processor/src/utils/constants/export-constants.ts
@@ -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,
],
};
diff --git a/apps/main-processor/src/utils/constants/ipc-validation.ts b/apps/main-processor/src/utils/constants/ipc-validation.ts
index b48925d..cbbe155 100644
--- a/apps/main-processor/src/utils/constants/ipc-validation.ts
+++ b/apps/main-processor/src/utils/constants/ipc-validation.ts
@@ -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']);
@@ -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';
diff --git a/apps/main-processor/src/utils/helper/setting-helper.ts b/apps/main-processor/src/utils/helper/setting-helper.ts
index 5e96b3d..90463af 100644
--- a/apps/main-processor/src/utils/helper/setting-helper.ts
+++ b/apps/main-processor/src/utils/helper/setting-helper.ts
@@ -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';
@@ -44,7 +44,7 @@ export function validateSettings(partial: Partial): Partial 10000) throw new Error('Invalid customCss');
validated.customCss = value;
}
if (key === 'zoom') {
diff --git a/apps/renderer/__tests__/config/marked.test.ts b/apps/renderer/__tests__/config/marked.test.ts
new file mode 100644
index 0000000..9a8304d
--- /dev/null
+++ b/apps/renderer/__tests__/config/marked.test.ts
@@ -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 `${props.text}-${count}`;
+ }),
+}));
+
+describe('get markdown instance and registry tests', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return the same instance on multiple calls', () => {
+ const registry1 = new Map();
+ const registry2 = new Map();
+ 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([['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([['Title', 1]]);
+ const secondRegistry = new Map([['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();
+ const instance = getMarkdown(emptyRegistry);
+ const parseAction = () => instance.parse('# Empty Test');
+ await expect(parseAction()).resolves.not.toThrow();
+ });
+});
diff --git a/apps/renderer/__tests__/renderer/markdown.test.ts b/apps/renderer/__tests__/renderer/markdown.test.ts
index f711eb0..9e8dd11 100644
--- a/apps/renderer/__tests__/renderer/markdown.test.ts
+++ b/apps/renderer/__tests__/renderer/markdown.test.ts
@@ -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('Hello World
');
+ const html2 = await renderMarkdown('# Hello World');
+ expect(html2).toContain('Hello World
');
+ expect(html2).not.toContain('id="hello-world-1"');
+ });
});
diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx
index ce554f6..f701f34 100644
--- a/apps/renderer/src/App.tsx
+++ b/apps/renderer/src/App.tsx
@@ -177,6 +177,7 @@ useShortcuts({
)}
{activeTab && !isLoading && !error && (
+
{!focusMode && (
-
+
-
+
)}
setShowToast(false)} />
diff --git a/apps/renderer/src/config/marked.ts b/apps/renderer/src/config/marked.ts
index f4de237..717d079 100644
--- a/apps/renderer/src/config/marked.ts
+++ b/apps/renderer/src/config/marked.ts
@@ -1,12 +1,17 @@
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;
export function getMarkdown(registry: Map): Marked {
- const instance = new Marked();
+ currentRegistry = registry;
+ if (instance) return instance;
+ instance = new Marked();
// configure marked with GFM options
instance.use({
@@ -14,7 +19,7 @@ export function getMarkdown(registry: Map): Marked {
breaks: false,
renderer: {
heading(props) {
- return heading(props, registry);
+ return heading(props, currentRegistry);
},
},
});
@@ -29,7 +34,7 @@ export function getMarkdown(registry: Map): Marked {
return escapeHtml(code);
}
const highlighter = await shikiHighlighter();
- const theme = THEMES[1];
+ const theme = DEFAULT_THEME;
try {
return highlighter.codeToHtml(code, {
lang: language,
diff --git a/apps/renderer/src/hooks/useFilePersistence.ts b/apps/renderer/src/hooks/useFilePersistence.ts
index 86a3c5c..7165b1e 100644
--- a/apps/renderer/src/hooks/useFilePersistence.ts
+++ b/apps/renderer/src/hooks/useFilePersistence.ts
@@ -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;
@@ -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);
diff --git a/apps/renderer/src/types/component-types.ts b/apps/renderer/src/types/component-types.ts
index 82a1cde..c83978c 100644
--- a/apps/renderer/src/types/component-types.ts
+++ b/apps/renderer/src/types/component-types.ts
@@ -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;
diff --git a/apps/renderer/src/utils/constants/theme-constants.ts b/apps/renderer/src/utils/constants/theme-constants.ts
index a405cc8..45e8951 100644
--- a/apps/renderer/src/utils/constants/theme-constants.ts
+++ b/apps/renderer/src/utils/constants/theme-constants.ts
@@ -6,3 +6,5 @@ export const APPTHEMES = [
'minimal',
'dracula',
] as const;
+
+export const DEFAULT_THEME = 'github-dark';