diff --git a/index.html b/index.html index 1a78fbc..76df38c 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Open Markdown diff --git a/src/main/index.ts b/src/main/index.ts index e859db1..b012860 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,10 @@ import { app, BrowserWindow } from 'electron'; import path from 'node:path'; import { registerAllHandlers } from './ipc/handlers'; +import { + registerAssetProtocolScheme, + registerAssetProtocolHandler, +} from './services/AssetProtocolService'; import { setupApplicationMenu } from './menu/applicationMenu'; import { getPreferencesService } from './services/PreferencesService'; import { getRecentFilesService } from './services/RecentFilesService'; @@ -21,6 +25,10 @@ interface PendingWindow { const pendingWindows = new Map(); let preReadyFilePath: string | null = null; +// Custom protocol used to serve local image assets must be registered as a +// privileged scheme before the app `ready` event fires. +registerAssetProtocolScheme(); + /** * Check command-line arguments for markdown file */ @@ -108,6 +116,9 @@ async function initialize(): Promise { await getPreferencesService().initialize(); await getRecentFilesService().initialize(); + // Serve local image assets referenced by markdown documents + registerAssetProtocolHandler(); + // Register IPC handlers before creating windows registerAllHandlers(); diff --git a/src/main/services/AssetProtocolService.ts b/src/main/services/AssetProtocolService.ts new file mode 100644 index 0000000..b1bd2f2 --- /dev/null +++ b/src/main/services/AssetProtocolService.ts @@ -0,0 +1,86 @@ +/** + * AssetProtocolService - Serves local image assets referenced by markdown + * documents through a dedicated custom protocol. + * + * Markdown files commonly reference images with relative or absolute + * filesystem paths (e.g. `docs/images/logo.svg`). Those cannot be loaded + * directly from the renderer: the document's origin is the app bundle (or the + * dev server), and the Content-Security-Policy forbids `file:` images. The + * renderer rewrites such references to `om-asset:` URLs which this handler + * resolves back to files on disk. + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { protocol } from 'electron'; + +import { ASSET_PROTOCOL_SCHEME } from '@shared/constants'; + +/** + * Image extensions the protocol is allowed to serve. Restricting to images + * keeps the handler from turning into an arbitrary local-file reader. + */ +const IMAGE_CONTENT_TYPES: Record = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.avif': 'image/avif', + '.apng': 'image/apng', +}; + +/** + * Privileged scheme registration. Must run before the app `ready` event. + */ +export function registerAssetProtocolScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: ASSET_PROTOCOL_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); +} + +/** + * Register the protocol handler. Must run after the app `ready` event. + */ +export function registerAssetProtocolHandler(): void { + protocol.handle(ASSET_PROTOCOL_SCHEME, async (request) => { + let filePath: string; + try { + // The renderer encodes the file path as the URL path under a fixed + // host; reattaching it to a `file:` URL yields the original path. + const { pathname } = new URL(request.url); + filePath = fileURLToPath(`file://${pathname}`); + } catch { + return new Response('Invalid asset URL', { status: 400 }); + } + + const contentType = IMAGE_CONTENT_TYPES[path.extname(filePath).toLowerCase()]; + if (!contentType) { + return new Response('Unsupported asset type', { status: 415 }); + } + + try { + const data = await readFile(filePath); + return new Response(data, { + headers: { + 'content-type': contentType, + 'cache-control': 'no-cache', + }, + }); + } catch { + return new Response('Asset not found', { status: 404 }); + } + }); +} diff --git a/src/preload/assetResolver.ts b/src/preload/assetResolver.ts new file mode 100644 index 0000000..0f39000 --- /dev/null +++ b/src/preload/assetResolver.ts @@ -0,0 +1,65 @@ +/** + * Resolves image references found in markdown documents to `om-asset:` URLs + * that the main process can serve from disk. + * + * Runs in the preload context, where Node's `path` module is available to + * resolve relative references against the document's location. + */ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { ASSET_PROTOCOL_SCHEME, ASSET_PROTOCOL_HOST } from '@shared/constants'; + +/** + * Resolve an image reference against the markdown file that contains it. + * + * @param baseFilePath - Absolute path to the markdown file being rendered. + * @param ref - The raw `src`/`srcset` reference from the document. + * @returns An `om-asset:` URL for local files, or `null` when the reference + * should be left untouched (already a URL, protocol-relative, or empty). + */ +export function resolveAssetUrl(baseFilePath: string, ref: string): string | null { + if (!baseFilePath || !ref) { + return null; + } + + const trimmed = ref.trim(); + if (!trimmed) { + return null; + } + + // Already an absolute URL (http:, https:, data:, blob:, file:, mailto:, ...). + // A single-character "scheme" is actually a Windows drive letter, so the + // scheme is required to be at least two characters long. + if (/^[a-z][a-z0-9+.-]+:/i.test(trimmed) || trimmed.startsWith('//')) { + return null; + } + + // Pure in-document anchors are not assets. + if (trimmed.startsWith('#')) { + return null; + } + + // Filesystem paths carry neither query strings nor fragments. + const cleaned = trimmed.replace(/[?#].*$/, ''); + if (!cleaned) { + return null; + } + + let decoded: string; + try { + decoded = decodeURIComponent(cleaned); + } catch { + decoded = cleaned; + } + + const resolved = path.isAbsolute(decoded) + ? decoded + : path.resolve(path.dirname(baseFilePath), decoded); + + // Embed the (already percent-encoded) file path under a fixed host. A bare + // `om-asset:///path` URL is mangled by Chromium's standard-scheme parser, + // which treats the first path segment as the host; a fixed host keeps the + // full path intact. + return `${ASSET_PROTOCOL_SCHEME}://${ASSET_PROTOCOL_HOST}${pathToFileURL(resolved).pathname}`; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index c9905bf..f4a575d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -5,6 +5,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { IPC_CHANNELS } from '@shared/types/api'; +import { resolveAssetUrl } from './assetResolver'; import type { ElectronAPI, @@ -334,6 +335,12 @@ const electronAPI: ElectronAPI = { }, }, + assets: { + resolve: (baseFilePath: string, ref: string): string | null => { + return resolveAssetUrl(baseFilePath, ref); + }, + }, + }; // Expose the API to the renderer process diff --git a/src/renderer/components/MarkdownViewer.ts b/src/renderer/components/MarkdownViewer.ts index c4daf03..5ed32d6 100644 --- a/src/renderer/components/MarkdownViewer.ts +++ b/src/renderer/components/MarkdownViewer.ts @@ -15,6 +15,8 @@ import { Toast } from './Toast'; import { EditModeController, createEditModeController } from './EditModeController'; import type { EditModeCallbacks } from './EditModeController'; +import { rewriteAssetPaths } from '../utils/assetPaths'; + import type { MarkdownPlugin, ContextMenuData, @@ -108,6 +110,21 @@ export class MarkdownViewer { } } + /** + * Resolve relative and absolute local image paths in the rendered content + * to the app's asset protocol so they load correctly. No-op until a file + * path is known (e.g. unsaved content), since paths cannot be resolved + * without a document location. + */ + private rewriteAssetPaths(): void { + const filePath = this.state.filePath; + if (!filePath) return; + + rewriteAssetPaths(this.container, (ref) => + window.electronAPI.assets.resolve(filePath, ref) + ); + } + /** * Render markdown content */ @@ -127,6 +144,9 @@ export class MarkdownViewer { const html = this.pluginManager.render(markdown); this.container.innerHTML = html; + // Resolve relative/local image paths against the document's location + this.rewriteAssetPaths(); + // Run post-render hooks (for Mermaid diagrams, etc.) await this.pluginManager.postRender(this.container); } catch (error) { diff --git a/src/renderer/utils/assetPaths.ts b/src/renderer/utils/assetPaths.ts new file mode 100644 index 0000000..d047968 --- /dev/null +++ b/src/renderer/utils/assetPaths.ts @@ -0,0 +1,75 @@ +/** + * Rewrites relative/absolute image references in rendered markdown so they + * point at the app's local asset protocol instead of failing to load. + * + * markdown-it emits ``/`` elements with the `src`/`srcset` values + * exactly as written in the document. References like `docs/images/logo.svg` + * would otherwise resolve against the app origin (or dev server) and never + * reach the file the author intended. + */ + +/** + * Resolves a single document reference to a loadable URL, or returns `null` + * to leave the reference untouched (e.g. it is already an absolute URL). + */ +export type AssetResolver = (ref: string) => string | null; + +/** + * Rewrite a `srcset` attribute value, resolving each candidate's URL while + * preserving its descriptor (e.g. `2x`, `640w`). + */ +export function rewriteSrcset(srcset: string, resolve: AssetResolver): string { + return srcset + .split(',') + .map((candidate) => { + const trimmed = candidate.trim(); + if (!trimmed) { + return ''; + } + const parts = trimmed.split(/\s+/); + const url = parts[0]; + if (url) { + const resolved = resolve(url); + if (resolved) { + parts[0] = resolved; + } + } + return parts.join(' '); + }) + .filter((candidate) => candidate.length > 0) + .join(', '); +} + +/** + * Rewrite every image reference within a rendered markdown container. + * + * Handles markdown image syntax and raw HTML alike, including `` + * elements which use `` plus an `` fallback. + */ +export function rewriteAssetPaths( + container: HTMLElement, + resolve: AssetResolver +): void { + const srcElements = container.querySelectorAll( + 'img[src], source[src]' + ); + srcElements.forEach((element) => { + const src = element.getAttribute('src'); + if (src) { + const resolved = resolve(src); + if (resolved) { + element.setAttribute('src', resolved); + } + } + }); + + const srcsetElements = container.querySelectorAll( + 'img[srcset], source[srcset]' + ); + srcsetElements.forEach((element) => { + const srcset = element.getAttribute('srcset'); + if (srcset) { + element.setAttribute('srcset', rewriteSrcset(srcset, resolve)); + } + }); +} diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 92d4052..7c8d7e4 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -46,6 +46,19 @@ export const BUILTIN_PLUGINS = { GITHUB_FLAVORED: 'github-flavored', } as const; +/** + * Custom protocol scheme used to serve local image assets referenced by + * relative or absolute filesystem paths in markdown documents. + */ +export const ASSET_PROTOCOL_SCHEME = 'om-asset'; + +/** + * Fixed host used in `om-asset:` URLs. The file path is carried in the URL + * path; a fixed host prevents Chromium's standard-scheme parser from + * consuming the first path segment as the host. + */ +export const ASSET_PROTOCOL_HOST = 'local'; + /** * Theme IDs */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 1f8e777..a15dbe4 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -258,6 +258,18 @@ export interface ShellAPI { openExternal: (url: string) => Promise; } +/** + * Local asset resolution API exposed to renderer + */ +export interface AssetsAPI { + /** + * Resolve an image reference from a markdown document to an `om-asset:` URL + * the app can load. Returns `null` when the reference is already a URL or + * otherwise should be left untouched. + */ + resolve: (baseFilePath: string, ref: string) => string | null; +} + /** * Complete Electron API exposed via contextBridge */ @@ -273,6 +285,7 @@ export interface ElectronAPI { recentFiles: RecentFilesAPI; menu: MenuAPI; shell: ShellAPI; + assets: AssetsAPI; } /** diff --git a/tests/unit/main/services/AssetProtocolService.test.ts b/tests/unit/main/services/AssetProtocolService.test.ts new file mode 100644 index 0000000..1dbce17 --- /dev/null +++ b/tests/unit/main/services/AssetProtocolService.test.ts @@ -0,0 +1,107 @@ +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +import { protocol } from 'electron'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + registerAssetProtocolScheme, + registerAssetProtocolHandler, +} from '../../../../src/main/services/AssetProtocolService'; + +vi.mock('electron', () => ({ + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +type ProtocolHandler = (request: Request) => Promise; + +function getRegisteredHandler(): ProtocolHandler { + registerAssetProtocolHandler(); + const calls = vi.mocked(protocol.handle).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall?.[0]).toBe('om-asset'); + return lastCall?.[1] as ProtocolHandler; +} + +function assetUrl(absolutePath: string): string { + return `om-asset://local${pathToFileURL(absolutePath).pathname}`; +} + +describe('AssetProtocolService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('registerAssetProtocolScheme', () => { + it('registers the om-asset scheme as privileged', () => { + registerAssetProtocolScheme(); + expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([ + { + scheme: 'om-asset', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); + }); + }); + + describe('protocol handler', () => { + it('serves an existing image with the correct content type', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from('PNGDATA')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/logo.png')) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await response.text()).toBe('PNGDATA'); + }); + + it('serves SVG files as image/svg+xml', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from('')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/logo.svg')) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/svg+xml'); + }); + + it('rejects non-image extensions with 415', async () => { + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/notes.txt')) + ); + + expect(response.status).toBe(415); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('returns 404 when the file cannot be read', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/missing.png')) + ); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/tests/unit/preload/assetResolver.test.ts b/tests/unit/preload/assetResolver.test.ts new file mode 100644 index 0000000..a628930 --- /dev/null +++ b/tests/unit/preload/assetResolver.test.ts @@ -0,0 +1,71 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { resolveAssetUrl } from '../../../src/preload/assetResolver'; + +const BASE = path.resolve('/docs/project/README.md'); + +function expectedUrl(absolutePath: string): string { + return `om-asset://local${pathToFileURL(absolutePath).pathname}`; +} + +describe('resolveAssetUrl', () => { + it('resolves a relative path against the document directory', () => { + const result = resolveAssetUrl(BASE, 'docs/images/logo.svg'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/docs/images/logo.svg')) + ); + }); + + it('resolves parent-relative paths', () => { + const result = resolveAssetUrl(BASE, '../shared/pic.png'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/shared/pic.png')) + ); + }); + + it('resolves absolute filesystem paths', () => { + const absolute = path.resolve('/var/assets/diagram.png'); + expect(resolveAssetUrl(BASE, absolute)).toBe(expectedUrl(absolute)); + }); + + it('leaves http(s) URLs untouched', () => { + expect(resolveAssetUrl(BASE, 'https://example.com/a.png')).toBeNull(); + expect(resolveAssetUrl(BASE, 'http://example.com/a.png')).toBeNull(); + }); + + it('leaves data and blob URLs untouched', () => { + expect(resolveAssetUrl(BASE, 'data:image/png;base64,AAAA')).toBeNull(); + expect(resolveAssetUrl(BASE, 'blob:abc-123')).toBeNull(); + }); + + it('leaves protocol-relative URLs untouched', () => { + expect(resolveAssetUrl(BASE, '//cdn.example.com/a.png')).toBeNull(); + }); + + it('leaves in-document anchors untouched', () => { + expect(resolveAssetUrl(BASE, '#section')).toBeNull(); + }); + + it('returns null for empty references', () => { + expect(resolveAssetUrl(BASE, '')).toBeNull(); + expect(resolveAssetUrl(BASE, ' ')).toBeNull(); + expect(resolveAssetUrl('', 'docs/logo.svg')).toBeNull(); + }); + + it('strips query strings and fragments from references', () => { + const result = resolveAssetUrl(BASE, 'images/logo.svg?v=2#frag'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/images/logo.svg')) + ); + }); + + it('decodes percent-encoded references', () => { + const result = resolveAssetUrl(BASE, 'images/my%20logo.svg'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/images/my logo.svg')) + ); + }); +}); diff --git a/tests/unit/renderer/utils/assetPaths.test.ts b/tests/unit/renderer/utils/assetPaths.test.ts new file mode 100644 index 0000000..dce8b80 --- /dev/null +++ b/tests/unit/renderer/utils/assetPaths.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; + +import { + rewriteSrcset, + rewriteAssetPaths, + type AssetResolver, +} from '../../../../src/renderer/utils/assetPaths'; + +// Resolver that mimics the preload behaviour: relative paths become +// `om-asset:` URLs, absolute URLs are left untouched. +const resolver: AssetResolver = (ref) => { + if (/^[a-z][a-z0-9+.-]+:/i.test(ref) || ref.startsWith('//')) { + return null; + } + return `om-asset:///docs/${ref}`; +}; + +describe('rewriteSrcset', () => { + it('rewrites a single-candidate srcset', () => { + expect(rewriteSrcset('images/logo.svg', resolver)).toBe( + 'om-asset:///docs/images/logo.svg' + ); + }); + + it('preserves descriptors for each candidate', () => { + const result = rewriteSrcset('a.png 1x, b.png 2x', resolver); + expect(result).toBe('om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x'); + }); + + it('leaves absolute URLs untouched', () => { + expect(rewriteSrcset('https://example.com/a.png 2x', resolver)).toBe( + 'https://example.com/a.png 2x' + ); + }); + + it('ignores empty candidates', () => { + expect(rewriteSrcset('a.png 1x, , b.png 2x', resolver)).toBe( + 'om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x' + ); + }); +}); + +describe('rewriteAssetPaths', () => { + function render(html: string): HTMLElement { + const container = document.createElement('div'); + container.innerHTML = html; + return container; + } + + it('rewrites a relative src', () => { + const container = render('logo'); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'om-asset:///docs/images/logo.svg' + ); + }); + + it('rewrites with and fallback', () => { + const container = render(` + + + + Logo + + `); + rewriteAssetPaths(container, resolver); + + const sources = container.querySelectorAll('source'); + expect(sources[0]?.getAttribute('srcset')).toBe( + 'om-asset:///docs/images/light.svg' + ); + expect(sources[1]?.getAttribute('srcset')).toBe( + 'om-asset:///docs/images/dark.svg' + ); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'om-asset:///docs/images/light.svg' + ); + }); + + it('leaves remote images untouched', () => { + const container = render(''); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/a.png' + ); + }); + + it('leaves data URIs untouched', () => { + const dataUri = 'data:image/png;base64,AAAA'; + const container = render(``); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUri); + }); +});