refactor: consolidate vault attachment paths
This commit is contained in:
@@ -377,6 +377,8 @@ A `vault_health_check` command detects stray files in non-protected subfolders a
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`. That module is the single owner for converting between portable markdown references such as `attachments/image.png`, Tauri asset URLs, and absolute active-vault filesystem paths. Editor markdown rendering, raw-mode serialization, image upload/drop handling, file-block open actions, and parsed image cleanup all call this primitive instead of carrying their own asset URL prefixes, Windows path normalization, or `attachments/` join rules.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { normalizeExternalUrl, openExternalUrl, openLocalFile } from '../utils/u
|
||||
import {
|
||||
isVaultAttachmentUrl,
|
||||
resolveVaultAttachmentPath,
|
||||
} from '../utils/vaultAttachmentUrls'
|
||||
} from '../utils/vaultAttachments'
|
||||
|
||||
const FILE_BLOCK_ACTION_SELECTOR = '[data-file-block] .bn-file-name-with-icon'
|
||||
const FILE_BLOCK_CONTAINER_SELECTOR = '[data-node-type="blockContainer"][data-id]'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { splitFrontmatter } from '../utils/wikilinks'
|
||||
import { slugifyNoteStem } from '../utils/noteSlug'
|
||||
import { isTauriAssetUrl } from '../utils/vaultAttachments'
|
||||
|
||||
type MarkdownContent = string
|
||||
type FilePath = string
|
||||
@@ -16,7 +17,6 @@ type ParsedBlock = {
|
||||
children?: unknown[]
|
||||
}
|
||||
|
||||
const LOCAL_FILE_URL_PREFIXES = ['asset://localhost/', 'http://asset.localhost/']
|
||||
const BROKEN_IMAGE_FALLBACK_MAX_WIDTH = 32
|
||||
|
||||
export function extractEditorBody(rawFileContent: MarkdownContent): MarkdownContent {
|
||||
@@ -78,8 +78,7 @@ function isParsedBlock(value: unknown): value is ParsedBlock {
|
||||
|
||||
function hasLocalFileUrl(block: ParsedBlock): boolean {
|
||||
const url = block.props?.url
|
||||
return typeof url === 'string'
|
||||
&& LOCAL_FILE_URL_PREFIXES.some(prefix => url.startsWith(prefix))
|
||||
return typeof url === 'string' && isTauriAssetUrl({ url })
|
||||
}
|
||||
|
||||
function hasSyntheticPreviewWidth(block: ParsedBlock): boolean {
|
||||
|
||||
@@ -245,6 +245,35 @@ describe('useImageDrop — Tauri native drag-drop', () => {
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('copies native image drops into the vault and emits attachment asset URLs', async () => {
|
||||
const onImageUrl = vi.fn()
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockClear()
|
||||
vi.mocked(convertFileSrc).mockClear()
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-photo.png')
|
||||
vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-photo.png')
|
||||
renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
||||
|
||||
await waitForNativeDropListeners()
|
||||
|
||||
act(() => {
|
||||
emitNativeDropEvent({
|
||||
type: 'drop',
|
||||
paths: ['/tmp/photo.png', '/tmp/readme.txt'],
|
||||
position: { x: 100, y: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onImageUrl).toHaveBeenCalledWith('asset://localhost/vault/attachments/123-photo.png')
|
||||
})
|
||||
expect(invoke).toHaveBeenCalledWith('copy_image_to_vault', {
|
||||
vaultPath: '/vault',
|
||||
sourcePath: '/tmp/photo.png',
|
||||
})
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri leave event', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { DragDropEvent as TauriDragDropPayload } from '@tauri-apps/api/webview'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { attachmentAssetUrlFromPath } from '../utils/vaultAttachments'
|
||||
|
||||
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff']
|
||||
@@ -11,6 +12,15 @@ const TAURI_DRAG_LEAVE_EVENT = 'tauri://drag-leave'
|
||||
|
||||
type ImageUrlHandler = (url: string) => void
|
||||
type TauriDropEvent = TauriEvent<TauriDragDropPayload>
|
||||
type CopyImageToVaultRequest = {
|
||||
sourcePath: string
|
||||
vaultPath: string
|
||||
}
|
||||
type DroppedImagesRequest = {
|
||||
imagePaths: string[]
|
||||
vaultPath: string | undefined
|
||||
onImageUrl: ImageUrlHandler | undefined
|
||||
}
|
||||
|
||||
function hasImageFiles(dt: DataTransfer): boolean {
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
@@ -37,7 +47,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
|
||||
filename: file.name,
|
||||
data: base64,
|
||||
})
|
||||
return convertFileSrc(savedPath)
|
||||
return attachmentAssetUrlFromPath({ path: savedPath })
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -48,21 +58,24 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
|
||||
}
|
||||
|
||||
/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */
|
||||
async function copyImageToVault(sourcePath: string, vaultPath: string): Promise<string> {
|
||||
async function copyImageToVault({
|
||||
sourcePath,
|
||||
vaultPath,
|
||||
}: CopyImageToVaultRequest): Promise<string> {
|
||||
const savedPath = await invoke<string>('copy_image_to_vault', { vaultPath, sourcePath })
|
||||
return convertFileSrc(savedPath)
|
||||
return attachmentAssetUrlFromPath({ path: savedPath })
|
||||
}
|
||||
|
||||
function insertDroppedImages(
|
||||
imagePaths: string[],
|
||||
vaultPath: string | undefined,
|
||||
onImageUrl: ImageUrlHandler | undefined,
|
||||
): void {
|
||||
function insertDroppedImages({
|
||||
imagePaths,
|
||||
vaultPath,
|
||||
onImageUrl,
|
||||
}: DroppedImagesRequest): void {
|
||||
if (imagePaths.length === 0) return
|
||||
if (!vaultPath || !onImageUrl) return
|
||||
|
||||
for (const sourcePath of imagePaths) {
|
||||
void copyImageToVault(sourcePath, vaultPath).then(onImageUrl)
|
||||
void copyImageToVault({ sourcePath, vaultPath }).then(onImageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +163,11 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
|
||||
const nextUnlisteners = await registerNativeDropListeners((event) => {
|
||||
if (event.payload.type === 'drop') {
|
||||
setIsDragOver(false)
|
||||
insertDroppedImages(
|
||||
event.payload.paths.filter(isImagePath),
|
||||
vaultPathRef.current,
|
||||
onImageUrlRef.current,
|
||||
)
|
||||
insertDroppedImages({
|
||||
imagePaths: event.payload.paths.filter(isImagePath),
|
||||
vaultPath: vaultPathRef.current,
|
||||
onImageUrl: onImageUrlRef.current,
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsDragOver(false)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isVaultAttachmentUrl, resolveVaultAttachmentPath } from './vaultAttachmentUrls'
|
||||
|
||||
describe('resolveVaultAttachmentPath', () => {
|
||||
it('resolves relative attachment URLs inside the active vault', () => {
|
||||
expect(resolveVaultAttachmentPath({ url: 'attachments/report.pdf', vaultPath: '/vault' })).toBe(
|
||||
'/vault/attachments/report.pdf',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves encoded Tauri asset URLs inside the active vault', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBe('/vault/attachments/report.pdf')
|
||||
})
|
||||
|
||||
it('rejects asset URLs outside the active vault', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/%2Fother%2Fattachments%2Freport.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unsafe relative attachment traversal', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'attachments/../secret.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Windows vault paths case-insensitive and separator-safe', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/C%3A%5CVault%5Cattachments%5CReport.pdf',
|
||||
vaultPath: 'c:\\vault',
|
||||
}),
|
||||
).toBe('C:\\Vault\\attachments\\Report.pdf')
|
||||
})
|
||||
|
||||
it('identifies attachment URLs before falling back to external link handling', () => {
|
||||
expect(isVaultAttachmentUrl({ url: 'attachments/report.pdf' })).toBe(true)
|
||||
expect(
|
||||
isVaultAttachmentUrl({
|
||||
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(isVaultAttachmentUrl({ url: 'https://example.com/report.pdf' })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { canWritePathToVault } from './vaultPathContainment'
|
||||
|
||||
const ASSET_URL_PREFIXES = [
|
||||
'asset://localhost/',
|
||||
'http://asset.localhost/',
|
||||
'asset://',
|
||||
]
|
||||
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
|
||||
|
||||
type StringValueRequest = {
|
||||
value: string
|
||||
}
|
||||
|
||||
type PathRequest = {
|
||||
path: string
|
||||
}
|
||||
|
||||
type UrlRequest = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type VaultAttachmentPathRequest = UrlRequest & {
|
||||
vaultPath?: string
|
||||
}
|
||||
|
||||
export type VaultAttachmentUrlRequest = Pick<VaultAttachmentPathRequest, 'url'>
|
||||
|
||||
function safeDecode({ value }: StringValueRequest): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeRelativeSegment({ path }: PathRequest): boolean {
|
||||
return path.split(/[\\/]/u).some(segment => segment === '..')
|
||||
}
|
||||
|
||||
function usesWindowsSeparators({ path }: PathRequest): boolean {
|
||||
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function joinVaultPath({
|
||||
relativePath,
|
||||
vaultPath,
|
||||
}: {
|
||||
relativePath: string
|
||||
vaultPath: string
|
||||
}): string {
|
||||
const separator = usesWindowsSeparators({ path: vaultPath }) ? '\\' : '/'
|
||||
const normalizedRelativePath = usesWindowsSeparators({ path: vaultPath })
|
||||
? relativePath.replace(/\//g, '\\')
|
||||
: relativePath.replace(/\\/g, '/')
|
||||
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
|
||||
return `${vaultPath}${joiner}${normalizedRelativePath}`
|
||||
}
|
||||
|
||||
function assetUrlPrefix({ url }: UrlRequest): string | null {
|
||||
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
|
||||
}
|
||||
|
||||
function restoreUnixRoot({ path }: PathRequest): string {
|
||||
if (isRootedPath({ path })) return path
|
||||
return `/${path}`
|
||||
}
|
||||
|
||||
function resolveAssetPath({ url }: UrlRequest): string | null {
|
||||
const prefix = assetUrlPrefix({ url })
|
||||
if (!prefix) return null
|
||||
return restoreUnixRoot({ path: safeDecode({ value: url.slice(prefix.length) }) })
|
||||
}
|
||||
|
||||
function isRootedPath({ path }: PathRequest): boolean {
|
||||
if (path.startsWith('/')) return true
|
||||
if (WINDOWS_DRIVE_PATH_PATTERN.test(path)) return true
|
||||
return path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function resolveRelativeAttachmentPath({
|
||||
url,
|
||||
vaultPath,
|
||||
}: {
|
||||
url: string
|
||||
vaultPath: string
|
||||
}): string | null {
|
||||
if (!url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) return null
|
||||
|
||||
const relativePath = safeDecode({ value: url })
|
||||
if (hasUnsafeRelativeSegment({ path: relativePath })) return null
|
||||
return joinVaultPath({ vaultPath, relativePath })
|
||||
}
|
||||
|
||||
export function isVaultAttachmentUrl({ url }: VaultAttachmentUrlRequest): boolean {
|
||||
const trimmedUrl = url.trim()
|
||||
return trimmedUrl.startsWith(RELATIVE_ATTACHMENTS_PREFIX)
|
||||
|| assetUrlPrefix({ url: trimmedUrl }) !== null
|
||||
}
|
||||
|
||||
export function resolveVaultAttachmentPath({
|
||||
url,
|
||||
vaultPath,
|
||||
}: VaultAttachmentPathRequest): string | null {
|
||||
const trimmedVaultPath = vaultPath?.trim()
|
||||
if (!trimmedVaultPath) return null
|
||||
|
||||
const trimmedUrl = url.trim()
|
||||
if (!trimmedUrl) return null
|
||||
|
||||
const candidatePath = resolveRelativeAttachmentPath({
|
||||
url: trimmedUrl,
|
||||
vaultPath: trimmedVaultPath,
|
||||
})
|
||||
?? resolveAssetPath({ url: trimmedUrl })
|
||||
?? trimmedUrl
|
||||
|
||||
return canWritePathToVault(candidatePath, trimmedVaultPath) ? candidatePath : null
|
||||
}
|
||||
106
src/utils/vaultAttachments.test.ts
Normal file
106
src/utils/vaultAttachments.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isTauriAssetUrl,
|
||||
isVaultAttachmentUrl,
|
||||
portableAttachmentPathFromAnyAssetUrl,
|
||||
portableAttachmentPathFromCurrentVaultAssetUrl,
|
||||
resolveVaultAttachmentPath,
|
||||
vaultAttachmentAssetUrl,
|
||||
} from './vaultAttachments'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
|
||||
}))
|
||||
|
||||
function assetUrl(path: string): string {
|
||||
return `asset://localhost/${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
describe('resolveVaultAttachmentPath', () => {
|
||||
it('resolves relative attachment URLs inside the active vault', () => {
|
||||
expect(resolveVaultAttachmentPath({ url: 'attachments/report.pdf', vaultPath: '/vault' })).toBe(
|
||||
'/vault/attachments/report.pdf',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves encoded Tauri asset URLs inside the active vault', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBe('/vault/attachments/report.pdf')
|
||||
})
|
||||
|
||||
it('rejects asset URLs outside the active vault', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/%2Fother%2Fattachments%2Freport.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unsafe relative attachment traversal', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'attachments/../secret.pdf',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Windows vault paths case-insensitive and separator-safe', () => {
|
||||
expect(
|
||||
resolveVaultAttachmentPath({
|
||||
url: 'asset://localhost/C%3A%5CVault%5Cattachments%5CReport.pdf',
|
||||
vaultPath: 'c:\\vault',
|
||||
}),
|
||||
).toBe('C:\\Vault\\attachments\\Report.pdf')
|
||||
})
|
||||
|
||||
it('identifies attachment URLs before falling back to external link handling', () => {
|
||||
expect(isVaultAttachmentUrl({ url: 'attachments/report.pdf' })).toBe(true)
|
||||
expect(
|
||||
isVaultAttachmentUrl({
|
||||
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(isVaultAttachmentUrl({ url: 'https://example.com/report.pdf' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vault attachment URL/path conversions', () => {
|
||||
it('builds asset URLs from portable attachment paths', () => {
|
||||
expect(
|
||||
vaultAttachmentAssetUrl({
|
||||
attachmentPath: 'attachments/shot.png',
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBe(assetUrl('/vault/attachments/shot.png'))
|
||||
})
|
||||
|
||||
it('converts current-vault asset URLs back to portable attachment paths', () => {
|
||||
expect(
|
||||
portableAttachmentPathFromCurrentVaultAssetUrl({
|
||||
url: assetUrl('/vault/attachments/shot.png'),
|
||||
vaultPath: '/vault',
|
||||
}),
|
||||
).toBe('attachments/shot.png')
|
||||
})
|
||||
|
||||
it('extracts portable attachment paths from legacy asset URLs in another vault', () => {
|
||||
expect(
|
||||
portableAttachmentPathFromAnyAssetUrl({
|
||||
url: assetUrl('/old-vault/attachments/shot.png'),
|
||||
}),
|
||||
).toBe('attachments/shot.png')
|
||||
})
|
||||
|
||||
it('identifies every Tauri asset URL form used by editor media flows', () => {
|
||||
expect(isTauriAssetUrl({ url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png' })).toBe(true)
|
||||
expect(isTauriAssetUrl({ url: 'http://asset.localhost/%2Fvault%2Fattachments%2Fshot.png' })).toBe(true)
|
||||
expect(isTauriAssetUrl({ url: 'asset:///vault/attachments/shot.png' })).toBe(true)
|
||||
expect(isTauriAssetUrl({ url: 'https://example.com/shot.png' })).toBe(false)
|
||||
})
|
||||
})
|
||||
238
src/utils/vaultAttachments.ts
Normal file
238
src/utils/vaultAttachments.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { canWritePathToVault } from './vaultPathContainment'
|
||||
|
||||
const LOCALHOST_ASSET_URL_PREFIX = 'asset://localhost/'
|
||||
const HTTP_ASSET_URL_PREFIX = 'http://asset.localhost/'
|
||||
const GENERIC_ASSET_URL_PREFIX = 'asset://'
|
||||
const ASSET_URL_PREFIXES = [
|
||||
LOCALHOST_ASSET_URL_PREFIX,
|
||||
HTTP_ASSET_URL_PREFIX,
|
||||
GENERIC_ASSET_URL_PREFIX,
|
||||
]
|
||||
const ATTACHMENTS_SEGMENT = '/attachments/'
|
||||
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
|
||||
const WINDOWS_EXTENDED_PATH_PREFIX = '\\\\?\\'
|
||||
const WINDOWS_EXTENDED_UNC_PREFIX = '\\\\?\\UNC\\'
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
|
||||
|
||||
type AbsolutePath = string
|
||||
type AttachmentPath = string
|
||||
type VaultPath = string
|
||||
|
||||
type UrlRequest = {
|
||||
url: string
|
||||
}
|
||||
|
||||
type PathRequest = {
|
||||
path: string
|
||||
}
|
||||
|
||||
type VaultPathRequest = {
|
||||
vaultPath: VaultPath
|
||||
}
|
||||
|
||||
type AttachmentPathRequest = {
|
||||
attachmentPath: AttachmentPath
|
||||
}
|
||||
|
||||
export type VaultAttachmentPathRequest = UrlRequest & {
|
||||
vaultPath?: VaultPath
|
||||
}
|
||||
|
||||
export type VaultAttachmentUrlRequest = UrlRequest
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeRelativeSegment({ path }: PathRequest): boolean {
|
||||
return path.split(/[\\/]/u).some(segment => segment === '..')
|
||||
}
|
||||
|
||||
function usesWindowsSeparators({ path }: PathRequest): boolean {
|
||||
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function relativePathForVault({
|
||||
attachmentPath,
|
||||
vaultPath,
|
||||
}: AttachmentPathRequest & VaultPathRequest): AttachmentPath {
|
||||
return usesWindowsSeparators({ path: vaultPath })
|
||||
? attachmentPath.replace(/\//g, '\\')
|
||||
: attachmentPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function removeWindowsExtendedPrefix(path: AbsolutePath): AbsolutePath {
|
||||
if (path.startsWith(WINDOWS_EXTENDED_UNC_PREFIX)) {
|
||||
return `\\\\${path.slice(WINDOWS_EXTENDED_UNC_PREFIX.length)}`
|
||||
}
|
||||
if (path.startsWith(WINDOWS_EXTENDED_PATH_PREFIX)) {
|
||||
return path.slice(WINDOWS_EXTENDED_PATH_PREFIX.length)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function normalizedFilesystemPath(path: AbsolutePath): AbsolutePath {
|
||||
return removeWindowsExtendedPrefix(path).replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function withoutTrailingSlash(path: AbsolutePath): AbsolutePath {
|
||||
return path.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function normalizeForComparison(path: AbsolutePath, vaultPath: VaultPath): AbsolutePath {
|
||||
const normalizedPath = withoutTrailingSlash(normalizedFilesystemPath(path))
|
||||
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
|
||||
return WINDOWS_DRIVE_PATH_PATTERN.test(normalizedPath)
|
||||
|| WINDOWS_DRIVE_PATH_PATTERN.test(normalizedVaultPath)
|
||||
? normalizedPath.toLowerCase()
|
||||
: normalizedPath
|
||||
}
|
||||
|
||||
function assetUrlPrefix({ url }: UrlRequest): string | null {
|
||||
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
|
||||
}
|
||||
|
||||
function isRootedPath({ path }: PathRequest): boolean {
|
||||
if (path.startsWith('/')) return true
|
||||
if (WINDOWS_DRIVE_PATH_PATTERN.test(path)) return true
|
||||
return path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function restoreUnixRoot({ path }: PathRequest): AbsolutePath {
|
||||
return isRootedPath({ path }) ? path : `/${path}`
|
||||
}
|
||||
|
||||
function resolveAssetPath({ url }: UrlRequest): AbsolutePath | null {
|
||||
const prefix = assetUrlPrefix({ url })
|
||||
if (!prefix) return null
|
||||
return restoreUnixRoot({ path: safeDecode(url.slice(prefix.length)) })
|
||||
}
|
||||
|
||||
function extractPortableAttachmentPath({ path }: PathRequest): AttachmentPath | null {
|
||||
const normalizedPath = normalizedFilesystemPath(path)
|
||||
const index = normalizedPath.lastIndexOf(ATTACHMENTS_SEGMENT)
|
||||
if (index === -1) return null
|
||||
|
||||
const filename = normalizedPath.slice(index + ATTACHMENTS_SEGMENT.length)
|
||||
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
|
||||
}
|
||||
|
||||
function currentVaultAttachmentPath({
|
||||
path,
|
||||
vaultPath,
|
||||
}: PathRequest & VaultPathRequest): AttachmentPath | null {
|
||||
const normalizedPath = normalizedFilesystemPath(path)
|
||||
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
|
||||
const attachmentsRoot = `${normalizedVaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
|
||||
const comparablePath = normalizeForComparison(normalizedPath, vaultPath)
|
||||
const comparableRoot = normalizeForComparison(attachmentsRoot, vaultPath)
|
||||
if (!comparablePath.startsWith(comparableRoot)) return null
|
||||
|
||||
const filename = normalizedPath.slice(attachmentsRoot.length)
|
||||
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
|
||||
}
|
||||
|
||||
function isPathInsideVault({
|
||||
path,
|
||||
vaultPath,
|
||||
}: PathRequest & VaultPathRequest): boolean {
|
||||
return canWritePathToVault(
|
||||
removeWindowsExtendedPrefix(path),
|
||||
removeWindowsExtendedPrefix(vaultPath),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRelativeAttachmentPath({
|
||||
url,
|
||||
vaultPath,
|
||||
}: UrlRequest & VaultPathRequest): AbsolutePath | null {
|
||||
if (!url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) return null
|
||||
|
||||
const attachmentPath = safeDecode(url)
|
||||
if (hasUnsafeRelativeSegment({ path: attachmentPath })) return null
|
||||
return vaultAttachmentPath({ vaultPath, attachmentPath })
|
||||
}
|
||||
|
||||
export function vaultAttachmentPath({
|
||||
attachmentPath,
|
||||
vaultPath,
|
||||
}: AttachmentPathRequest & VaultPathRequest): AbsolutePath {
|
||||
const separator = usesWindowsSeparators({ path: vaultPath }) ? '\\' : '/'
|
||||
const normalizedAttachmentPath = relativePathForVault({ vaultPath, attachmentPath })
|
||||
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
|
||||
return `${vaultPath}${joiner}${normalizedAttachmentPath}`
|
||||
}
|
||||
|
||||
export function attachmentAssetUrlFromPath({ path }: PathRequest): string {
|
||||
return convertFileSrc(path)
|
||||
}
|
||||
|
||||
export function vaultAttachmentAssetUrl({
|
||||
attachmentPath,
|
||||
vaultPath,
|
||||
}: AttachmentPathRequest & VaultPathRequest): string {
|
||||
return attachmentAssetUrlFromPath({
|
||||
path: vaultAttachmentPath({ vaultPath, attachmentPath }),
|
||||
})
|
||||
}
|
||||
|
||||
export function isPortableAttachmentPath({ path }: PathRequest): boolean {
|
||||
return path.startsWith(RELATIVE_ATTACHMENTS_PREFIX)
|
||||
}
|
||||
|
||||
export function isTauriAssetUrl({ url }: UrlRequest): boolean {
|
||||
return assetUrlPrefix({ url }) !== null
|
||||
}
|
||||
|
||||
export function isCurrentVaultAssetUrl({
|
||||
url,
|
||||
vaultPath,
|
||||
}: UrlRequest & VaultPathRequest): boolean {
|
||||
const path = resolveAssetPath({ url })
|
||||
return path ? isPathInsideVault({ path, vaultPath }) : false
|
||||
}
|
||||
|
||||
export function portableAttachmentPathFromCurrentVaultAssetUrl({
|
||||
url,
|
||||
vaultPath,
|
||||
}: UrlRequest & VaultPathRequest): AttachmentPath | null {
|
||||
const path = resolveAssetPath({ url })
|
||||
if (!path || !isPathInsideVault({ path, vaultPath })) return null
|
||||
return currentVaultAttachmentPath({ path, vaultPath })
|
||||
}
|
||||
|
||||
export function portableAttachmentPathFromAnyAssetUrl({ url }: UrlRequest): AttachmentPath | null {
|
||||
const path = resolveAssetPath({ url })
|
||||
return path ? extractPortableAttachmentPath({ path }) : null
|
||||
}
|
||||
|
||||
export function isVaultAttachmentUrl({ url }: VaultAttachmentUrlRequest): boolean {
|
||||
const trimmedUrl = url.trim()
|
||||
return isPortableAttachmentPath({ path: trimmedUrl })
|
||||
|| isTauriAssetUrl({ url: trimmedUrl })
|
||||
}
|
||||
|
||||
export function resolveVaultAttachmentPath({
|
||||
url,
|
||||
vaultPath,
|
||||
}: VaultAttachmentPathRequest): AbsolutePath | null {
|
||||
const trimmedVaultPath = vaultPath?.trim()
|
||||
if (!trimmedVaultPath) return null
|
||||
|
||||
const trimmedUrl = url.trim()
|
||||
if (!trimmedUrl) return null
|
||||
|
||||
const candidatePath = resolveRelativeAttachmentPath({
|
||||
url: trimmedUrl,
|
||||
vaultPath: trimmedVaultPath,
|
||||
})
|
||||
?? resolveAssetPath({ url: trimmedUrl })
|
||||
?? trimmedUrl
|
||||
|
||||
return isPathInsideVault({ path: candidatePath, vaultPath: trimmedVaultPath }) ? candidatePath : null
|
||||
}
|
||||
@@ -1,101 +1,20 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
const ASSET_URL_PREFIX = 'asset://localhost/'
|
||||
const HTTP_ASSET_URL_PREFIX = 'http://asset.localhost/'
|
||||
const ASSET_URL_PREFIXES = [ASSET_URL_PREFIX, HTTP_ASSET_URL_PREFIX]
|
||||
const ATTACHMENTS_SEGMENT = '/attachments/'
|
||||
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
|
||||
const WINDOWS_EXTENDED_PATH_PREFIX = '\\\\?\\'
|
||||
const WINDOWS_EXTENDED_UNC_PREFIX = '\\\\?\\UNC\\'
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
|
||||
import {
|
||||
isCurrentVaultAssetUrl,
|
||||
isPortableAttachmentPath,
|
||||
isTauriAssetUrl,
|
||||
portableAttachmentPathFromAnyAssetUrl,
|
||||
portableAttachmentPathFromCurrentVaultAssetUrl,
|
||||
vaultAttachmentAssetUrl,
|
||||
} from './vaultAttachments'
|
||||
|
||||
type Markdown = string
|
||||
type VaultPath = string
|
||||
type AttachmentPath = string
|
||||
type AbsolutePath = string
|
||||
type MarkdownImageUrl = string
|
||||
|
||||
// Matches markdown image syntax:  or .
|
||||
const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g
|
||||
|
||||
function assetUrl(path: AbsolutePath): MarkdownImageUrl {
|
||||
return convertFileSrc(path)
|
||||
}
|
||||
|
||||
function usesWindowsSeparators(path: string): boolean {
|
||||
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
function relativePathForVault(vaultPath: VaultPath, attachmentPath: AttachmentPath): AttachmentPath {
|
||||
return usesWindowsSeparators(vaultPath)
|
||||
? attachmentPath.replace(/\//g, '\\')
|
||||
: attachmentPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath {
|
||||
const separator = usesWindowsSeparators(vaultPath) ? '\\' : '/'
|
||||
const normalizedAttachmentPath = relativePathForVault(vaultPath, attachmentPath)
|
||||
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
|
||||
return `${vaultPath}${joiner}${normalizedAttachmentPath}`
|
||||
}
|
||||
|
||||
function removeWindowsExtendedPrefix(path: AbsolutePath): AbsolutePath {
|
||||
if (path.startsWith(WINDOWS_EXTENDED_UNC_PREFIX)) {
|
||||
return `\\\\${path.slice(WINDOWS_EXTENDED_UNC_PREFIX.length)}`
|
||||
}
|
||||
if (path.startsWith(WINDOWS_EXTENDED_PATH_PREFIX)) {
|
||||
return path.slice(WINDOWS_EXTENDED_PATH_PREFIX.length)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function normalizedFilesystemPath(path: AbsolutePath): AbsolutePath {
|
||||
return removeWindowsExtendedPrefix(path).replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function withoutTrailingSlash(path: AbsolutePath): AbsolutePath {
|
||||
return path.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | null {
|
||||
const normalizedPath = normalizedFilesystemPath(absolutePath)
|
||||
const index = normalizedPath.lastIndexOf(ATTACHMENTS_SEGMENT)
|
||||
if (index === -1) return null
|
||||
|
||||
const filename = normalizedPath.slice(index + ATTACHMENTS_SEGMENT.length)
|
||||
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
|
||||
}
|
||||
|
||||
function assetUrlPrefix(url: MarkdownImageUrl): string | null {
|
||||
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
|
||||
}
|
||||
|
||||
function decodeAssetPath(url: MarkdownImageUrl): AbsolutePath {
|
||||
const prefix = assetUrlPrefix(url)
|
||||
return prefix ? decodeURIComponent(url.slice(prefix.length)) : ''
|
||||
}
|
||||
|
||||
function isAssetUrl(url: MarkdownImageUrl): boolean {
|
||||
return assetUrlPrefix(url) !== null
|
||||
}
|
||||
|
||||
function isCurrentVaultAsset(url: MarkdownImageUrl, vaultPath: VaultPath): boolean {
|
||||
const absolutePath = withoutTrailingSlash(normalizedFilesystemPath(decodeAssetPath(url)))
|
||||
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
|
||||
return absolutePath === normalizedVaultPath || absolutePath.startsWith(`${normalizedVaultPath}/`)
|
||||
}
|
||||
|
||||
function currentVaultAttachmentPath(url: MarkdownImageUrl, vaultPath: VaultPath): AttachmentPath | null {
|
||||
const absolutePath = normalizedFilesystemPath(decodeAssetPath(url))
|
||||
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
|
||||
const attachmentsPrefix = `${normalizedVaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
|
||||
if (!absolutePath.startsWith(attachmentsPrefix)) return null
|
||||
|
||||
const filename = absolutePath.slice(attachmentsPrefix.length)
|
||||
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
|
||||
}
|
||||
|
||||
function rewriteMarkdownImages(
|
||||
markdown: Markdown,
|
||||
transformUrl: (url: MarkdownImageUrl) => MarkdownImageUrl | null,
|
||||
@@ -110,16 +29,16 @@ export function resolveImageUrls(markdown: Markdown, vaultPath: VaultPath): Mark
|
||||
if (!isTauri() || !vaultPath) return markdown
|
||||
|
||||
return rewriteMarkdownImages(markdown, (url) => {
|
||||
if (url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) {
|
||||
return assetUrl(vaultAttachmentPath(vaultPath, url))
|
||||
if (isPortableAttachmentPath({ path: url })) {
|
||||
return vaultAttachmentAssetUrl({ vaultPath, attachmentPath: url })
|
||||
}
|
||||
|
||||
if (!isAssetUrl(url) || isCurrentVaultAsset(url, vaultPath)) {
|
||||
if (!isTauriAssetUrl({ url }) || isCurrentVaultAssetUrl({ url, vaultPath })) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attachmentPath = extractAttachmentPath(decodeAssetPath(url))
|
||||
return attachmentPath ? assetUrl(vaultAttachmentPath(vaultPath, attachmentPath)) : null
|
||||
const attachmentPath = portableAttachmentPathFromAnyAssetUrl({ url })
|
||||
return attachmentPath ? vaultAttachmentAssetUrl({ vaultPath, attachmentPath }) : null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,8 +46,8 @@ export function portableImageUrls(markdown: Markdown, vaultPath: VaultPath): Mar
|
||||
if (!vaultPath) return markdown
|
||||
|
||||
return rewriteMarkdownImages(markdown, (url) => {
|
||||
if (!isAssetUrl(url)) return null
|
||||
if (!isTauriAssetUrl({ url })) return null
|
||||
|
||||
return currentVaultAttachmentPath(url, vaultPath)
|
||||
return portableAttachmentPathFromCurrentVaultAssetUrl({ url, vaultPath })
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user