Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions apps/desktop/src-tauri/src/fs/asset_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
//! commands — a request racing a graph switch is refused, never resolved
//! against the new graph. The path must live under `assets/` and passes the
//! shared symlink-aware traversal guard before any IO.
//! Passive previews append `?reflect-preview=raster`; those responses are
//! served only when byte sniffing identifies PNG, JPEG, GIF, or WebP content,
//! so an SVG renamed with a raster extension cannot load subresources there.

use std::borrow::Cow;

Expand All @@ -28,6 +31,7 @@ use super::GraphState;
/// The scheme name, shared with the `lib.rs` registration. The frontend and
/// the CSP `img-src` grant in `tauri.conf.json` spell it out literally.
pub(crate) const SCHEME: &str = "reflect-asset";
const PREVIEW_RASTER_QUERY: &str = "reflect-preview=raster";

/// Protocol entry point (`register_asynchronous_uri_scheme_protocol`). Runs
/// on the webview's calling thread — on WebKit, the app's main thread — so it
Expand All @@ -42,33 +46,55 @@ pub(crate) fn handle<R: Runtime>(
let request_path = percent_encoding::percent_decode(&request.uri().path().as_bytes()[1..])
.decode_utf8_lossy()
.into_owned();
let preview_raster_only = requests_preview_raster(request.uri().query());
let method_allowed = request.method() == tauri::http::Method::GET;
tauri::async_runtime::spawn_blocking(move || {
if !method_allowed {
responder.respond(status_response(StatusCode::METHOD_NOT_ALLOWED));
return;
}
responder.respond(response_for(&app, &request_path));
responder.respond(response_for(&app, &request_path, preview_raster_only));
});
}

fn response_for<R: Runtime>(
app: &AppHandle<R>,
request_path: &str,
preview_raster_only: bool,
) -> Response<Cow<'static, [u8]>> {
match serve(app, request_path) {
Ok((mime, bytes)) => Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, bytes.len())
.body(Cow::Owned(bytes))
.unwrap_or_else(|_| status_response(StatusCode::INTERNAL_SERVER_ERROR)),
Ok((mime, bytes)) => {
if preview_raster_only && !is_preview_safe_raster_mime(&mime) {
return status_response(StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, bytes.len())
.body(Cow::Owned(bytes))
.unwrap_or_else(|_| status_response(StatusCode::INTERNAL_SERVER_ERROR))
}
Err(status) => {
tracing::warn!(path = request_path, %status, "asset protocol refused a request");
status_response(status)
}
}
}

fn requests_preview_raster(query: Option<&str>) -> bool {
query.is_some_and(|query| {
query
.split('&')
.any(|parameter| parameter == PREVIEW_RASTER_QUERY)
})
}

fn is_preview_safe_raster_mime(mime: &str) -> bool {
matches!(
mime,
"image/png" | "image/jpeg" | "image/gif" | "image/webp"
)
}

fn status_response(status: StatusCode) -> Response<Cow<'static, [u8]>> {
Response::builder()
.status(status)
Expand Down Expand Up @@ -155,4 +181,27 @@ mod tests {
StatusCode::FORBIDDEN,
);
}

#[test]
fn recognizes_only_the_explicit_preview_raster_query() {
assert!(requests_preview_raster(Some("reflect-preview=raster")));
assert!(requests_preview_raster(Some(
"cache=1&reflect-preview=raster"
)));
assert!(!requests_preview_raster(None));
assert!(!requests_preview_raster(Some("reflect-preview=svg")));
}

#[test]
fn preview_raster_filter_uses_sniffed_content_not_the_filename() {
let disguised_svg = br#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#;
let svg_mime = MimeType::parse(disguised_svg, "assets/disguised.png");
assert_ne!(svg_mime, "image/png");
assert!(!is_preview_safe_raster_mime(&svg_mime));

let png_signature = b"\x89PNG\r\n\x1a\n";
let png_mime = MimeType::parse(png_signature, "assets/image.bin");
assert_eq!(png_mime, "image/png");
assert!(is_preview_safe_raster_mime(&png_mime));
}
}
12 changes: 12 additions & 0 deletions apps/desktop/src/components/note-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { useNoteDocument } from '@/editor/use-note-document'
import { useTagNavigation } from '@/editor/use-tag-navigation'
import { useTemplateSlashItems } from '@/editor/use-template-slash-items'
import { useWikiLinkNavigation } from '@/editor/use-wiki-link-navigation'
import { useWikiLinkHoverPreview } from '@/editor/use-wiki-link-hover-preview'
import { isTouchEditorSurface } from '@/lib/platform-surface'
import { cn } from '@/lib/utils'
import { useGraph } from '@/providers/graph-provider'
import { useSettings } from '@/providers/settings-provider'
Expand Down Expand Up @@ -157,6 +159,13 @@ export function NotePaneComponent({
resolveFileInfo,
saveError,
} = useAssetPersistence(generation, path)
const renderWikilinkHoverCard = useWikiLinkHoverPreview({
generation,
graphKey: graph?.root ?? null,
dateFormat: settings.dateFormat,
resolveImageUrl,
resolveAssetOpenPath,
})
const onWikiLinkClick = useWikiLinkNavigation(generation)
const onTagClick = useTagNavigation()
const { onWikilinkSearch, onTagSearch } = useEditorAutocomplete()
Expand Down Expand Up @@ -340,6 +349,9 @@ export function NotePaneComponent({
resolveFileLink={resolveAssetFileLink}
resolveFileInfo={resolveFileInfo}
onWikiLinkClick={onWikiLinkClick}
{...(generation !== null && !isTouchEditorSurface()
? { renderWikilinkHoverCard }
: {})}
onTagClick={onTagClick}
onWikilinkSearch={onWikilinkSearch}
onTagSearch={onTagSearch}
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/components/route-content.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { act, render, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactElement } from 'react'
import { setBridge } from '@reflect/core'
import { PaletteProvider, usePalette } from '@/components/command-palette/palette-provider'
import { flushOpenDocuments } from '@/editor/open-documents'
import type { NoteEditorHandle } from '@/editor/note-editor'
import { RouterProvider } from '@/routing/router'
import type { Route } from '@/routing/route'
import { setPlatformSurface } from '@/lib/platform-surface'
import { RouteContent } from './route-content'

/**
Expand All @@ -21,6 +22,7 @@ import { RouteContent } from './route-content'
const editorProbe = vi.hoisted(() => ({
onChange: null as ((markdown: string) => void) | null,
focusCalls: [] as string[],
hoverRenderer: null as boolean | null,
}))

vi.mock('@/editor/note-editor', async () => {
Expand All @@ -30,11 +32,14 @@ vi.mock('@/editor/note-editor', async () => {
initialContent,
onChange,
handleRef,
renderWikilinkHoverCard,
}: {
initialContent: string
onChange: (markdown: string) => void
handleRef?: (handle: NoteEditorHandle | null) => void
renderWikilinkHoverCard?: unknown
}) => {
editorProbe.hoverRenderer = renderWikilinkHoverCard !== undefined
const markdownRef = useRef(initialContent)
editorProbe.onChange = (markdown) => {
markdownRef.current = markdown
Expand Down Expand Up @@ -129,6 +134,7 @@ beforeEach(() => {
writes = []
editorProbe.onChange = null
editorProbe.focusCalls.length = 0
editorProbe.hoverRenderer = null
mockInvoke.mockReset()
mockInvoke.mockImplementation(async (command, args) => {
if (command === 'note_read') {
Expand All @@ -151,6 +157,10 @@ beforeEach(() => {
})
})

afterEach(() => {
setPlatformSurface({ touchEditor: false, mobileApp: false })
})

function PaletteProbe(): ReactElement {
const { open, query } = usePalette()
return <output data-testid="palette">{JSON.stringify({ open, query })}</output>
Expand Down Expand Up @@ -190,12 +200,23 @@ describe('RouteContent', () => {
await view.findByLabelText('Editing notes/exist.md')
expect(view.queryByTestId('daily-stream')).toBeNull()
expect(view.getByTestId('fake-editor').textContent).toContain('# Hello')
expect(editorProbe.hoverRenderer).toBe(true)

// The navigated-to note takes focus on mount.
await waitFor(() => expect(editorProbe.focusCalls).toContain('focus'))
view.unmount()
})

it('omits the wiki-link hover renderer on a touch editor surface', async () => {
setPlatformSurface({ touchEditor: true })
files['notes/exist.md'] = '# Hello\n'
const view = renderRoute({ kind: 'note', path: 'notes/exist.md' })

await view.findByLabelText('Editing notes/exist.md')
expect(editorProbe.hoverRenderer).toBe(false)
view.unmount()
})

it('opens a missing note seeded with an empty focused title, writing nothing', async () => {
const view = renderRoute({ kind: 'note', path: 'notes/new.md' })

Expand Down
Loading
Loading