From 9cdea1531cb119e7bd84f23b5fef861cee5eaee8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 14:10:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(v028):=20IPC=20inbox:open-media=20+=20path?= =?UTF-8?q?=20traversal=20+=20NoteCard=20cast=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 3 +- src/main/ipc/inboxApi.ts | 18 ++++++- src/preload/index.ts | 2 + src/renderer/inbox/components/NoteCard.tsx | 2 +- src/shared/types.ts | 2 + tests/unit/inboxApi-openMedia.test.ts | 62 ++++++++++++++++++++++ 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/unit/inboxApi-openMedia.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 9dd7611..949d546 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -165,7 +165,8 @@ app.whenReady().then(async () => { registerCaptureApi(capture, getQuickCaptureWindow); registerInboxApi({ repo, continuity, capture, health, intent, - getInboxWindow, settings: settingsSvc, providerHolder + getInboxWindow, settings: settingsSvc, providerHolder, + paths: { profileDir: paths.profileDir } }); // registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가 // 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동. diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 16d1b81..a95b639 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -1,6 +1,7 @@ import electron from 'electron'; import type { BrowserWindow } from 'electron'; -const { ipcMain, dialog } = electron; +const { ipcMain, dialog, shell } = electron; +import { join, normalize, sep } from 'node:path'; import type { NoteRepository } from '../repository/NoteRepository.js'; import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; @@ -21,6 +22,8 @@ export interface InboxIpcDeps { getInboxWindow: () => BrowserWindow | null; settings: SettingsService; providerHolder: ProviderHolder; + // v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline. + paths: { profileDir: string }; } export function registerInboxApi(deps: InboxIpcDeps): void { @@ -153,6 +156,19 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return s.ollama ?? null; }); + // v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3). + // path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1). + ipcMain.handle('inbox:open-media', async (_e, relPath: string) => { + const profileDir = deps.paths.profileDir; + const mediaRoot = join(profileDir, 'media'); + const target = normalize(join(profileDir, relPath)); + if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) { + return { ok: false as const, reason: 'invalid path' as const }; + } + await shell.openPath(target); + return { ok: true as const }; + }); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/preload/index.ts b/src/preload/index.ts index e80982b..6ffe824 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -66,6 +66,8 @@ const api: InklingApi = { getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'), openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'), copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'), + // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). + openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath), } }; diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 5a56721..4f96f34 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -339,7 +339,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore src={`inkling-media://${m.relPath}`} alt="" title={m.relPath} - onClick={() => { void (inboxApi as unknown as { openMedia: (rel: string) => Promise }).openMedia(m.relPath); }} + onClick={() => { void inboxApi.openMedia(m.relPath); }} style={{ width: 48, height: 48, diff --git a/src/shared/types.ts b/src/shared/types.ts index 883651e..4e78fd8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -126,6 +126,8 @@ export interface InboxApi { }>; openProfileDir(): Promise; copyAppInfo(): Promise; + // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). + openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>; } export interface InklingApi { diff --git a/tests/unit/inboxApi-openMedia.test.ts b/tests/unit/inboxApi-openMedia.test.ts new file mode 100644 index 0000000..0625653 --- /dev/null +++ b/tests/unit/inboxApi-openMedia.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { join } from 'node:path'; + +const { handlers, mockOpenPath } = vi.hoisted(() => ({ + handlers: {} as Record unknown>, + mockOpenPath: vi.fn(async () => '') +})); + +vi.mock('electron', () => ({ + default: { + ipcMain: { + handle: (ch: string, fn: (...args: unknown[]) => unknown) => { + handlers[ch] = fn; + } + }, + dialog: {}, + shell: { openPath: mockOpenPath } + } +})); + +import { registerInboxApi } from '../../src/main/ipc/inboxApi'; + +function makeDeps(profileDir: string): Parameters[0] { + // Minimal stub — `inbox:open-media` 핸들러는 deps.paths.profileDir 만 참조. + return { + repo: {} as never, + continuity: {} as never, + capture: {} as never, + health: {} as never, + intent: {} as never, + getInboxWindow: () => null, + settings: {} as never, + providerHolder: {} as never, + paths: { profileDir } + }; +} + +describe('inbox:open-media IPC', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + mockOpenPath.mockClear(); + }); + + it('opens valid relPath with shell.openPath', async () => { + registerInboxApi(makeDeps('/profile')); + const handler = handlers['inbox:open-media']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, 'media/note1/img.png'); + expect(r).toEqual({ ok: true }); + expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png')); + }); + + it('rejects path traversal with reason "invalid path"', async () => { + registerInboxApi(makeDeps('/profile')); + const handler = handlers['inbox:open-media']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, '../etc/passwd') as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid path'); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); +});