feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리
This commit is contained in:
@@ -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/... 초기화 직후로 이동.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<unknown> }).openMedia(m.relPath); }}
|
||||
onClick={() => { void inboxApi.openMedia(m.relPath); }}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface InboxApi {
|
||||
}>;
|
||||
openProfileDir(): Promise<void>;
|
||||
copyAppInfo(): Promise<void>;
|
||||
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
|
||||
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
62
tests/unit/inboxApi-openMedia.test.ts
Normal file
62
tests/unit/inboxApi-openMedia.test.ts
Normal file
@@ -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<string, (...args: unknown[]) => 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<typeof registerInboxApi>[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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user