fix: use stable asset URLs for image uploads (blob URL fix)
This commit is contained in:
96
e2e/image-upload.spec.ts
Normal file
96
e2e/image-upload.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
|
||||
// Minimal valid PNG: 1x1 red pixel
|
||||
const TEST_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
|
||||
|
||||
function createTestPng(filepath: string) {
|
||||
fs.mkdirSync(path.dirname(filepath), { recursive: true })
|
||||
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
|
||||
}
|
||||
|
||||
test('image upload via file picker displays image with data URL', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Open a note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
await editor.click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Insert image block via slash command
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(100)
|
||||
await page.keyboard.type('/image', { delay: 80 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select Image from slash menu (press Enter to pick first match)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify Upload tab is available (uploadFile is configured)
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
expect(await fileInput.count()).toBeGreaterThan(0)
|
||||
|
||||
// Upload a test image
|
||||
const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png')
|
||||
createTestPng(testImagePath)
|
||||
|
||||
await fileInput.first().setInputFiles(testImagePath)
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Verify: image element exists in the editor
|
||||
const images = page.locator('.bn-editor img')
|
||||
const imageCount = await images.count()
|
||||
expect(imageCount).toBeGreaterThan(0)
|
||||
|
||||
// Verify: image uses data URL (stable, survives reload in dev mode)
|
||||
const src = await images.first().getAttribute('src')
|
||||
expect(src).toMatch(/^data:/)
|
||||
|
||||
// Verify: no "Loading..." elements remain
|
||||
const loadingEls = page.locator('.bn-file-loading-preview')
|
||||
expect(await loadingEls.count()).toBe(0)
|
||||
|
||||
await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true })
|
||||
|
||||
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
|
||||
})
|
||||
|
||||
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click first note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Capture console errors
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
// Insert an image block via slash command
|
||||
await editor.click()
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.type('/image', { delay: 30 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Press Enter to select Image
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true })
|
||||
|
||||
// No errors related to upload should have occurred
|
||||
const uploadErrors = errors.filter(e => e.includes('upload'))
|
||||
expect(uploadErrors).toHaveLength(0)
|
||||
})
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -1871,6 +1871,7 @@ dependencies = [
|
||||
name = "laputa"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
|
||||
@@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -58,6 +58,11 @@ async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -81,7 +86,8 @@ pub fn run() {
|
||||
get_file_diff,
|
||||
git_commit,
|
||||
git_push,
|
||||
ai_chat
|
||||
ai_chat,
|
||||
save_image
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -658,6 +658,47 @@ pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, Stri
|
||||
with_frontmatter(path, |content| update_frontmatter_content(content, key, None))
|
||||
}
|
||||
|
||||
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
|
||||
fn is_safe_filename_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '.' | '-' | '_')
|
||||
}
|
||||
|
||||
/// Sanitize a filename by replacing unsafe characters with underscores.
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if is_safe_filename_char(c) { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save an uploaded image to the vault's attachments directory.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let vault = Path::new(vault_path);
|
||||
let attachments_dir = vault.join("attachments");
|
||||
|
||||
fs::create_dir_all(&attachments_dir)
|
||||
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
|
||||
|
||||
// Generate unique filename to avoid collisions
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
|
||||
let target_path = attachments_dir.join(&unique_name);
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
fs::write(&target_path, bytes)
|
||||
.map_err(|e| format!("Failed to write image: {}", e))?;
|
||||
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1471,4 +1512,62 @@ References:
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
|
||||
// --- save_image tests ---
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_safe_chars() {
|
||||
assert_eq!(sanitize_filename("photo.png"), "photo.png");
|
||||
assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_unsafe_chars() {
|
||||
assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png");
|
||||
assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_creates_file() {
|
||||
use base64::Engine;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data");
|
||||
|
||||
let result = save_image(vault_path, "test.png", &data);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let saved_path = result.unwrap();
|
||||
assert!(std::path::Path::new(&saved_path).exists());
|
||||
assert!(saved_path.contains("attachments"));
|
||||
assert!(saved_path.contains("test.png"));
|
||||
|
||||
let content = fs::read(&saved_path).unwrap();
|
||||
assert_eq!(content, b"fake image data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_creates_attachments_dir() {
|
||||
use base64::Engine;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let attachments = dir.path().join("attachments");
|
||||
assert!(!attachments.exists());
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(b"test");
|
||||
save_image(vault_path, "img.png", &data).unwrap();
|
||||
assert!(attachments.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_invalid_base64() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let result = save_image(vault_path, "test.png", "not-valid-base64!!!");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid base64"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -182,6 +182,7 @@ function App() {
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={() => setShowAIChat(c => !c)}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { filterSuggestionItems } from '@blocknote/core/extensions'
|
||||
import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuController } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
@@ -45,6 +47,7 @@ interface EditorProps {
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
vaultPath?: string
|
||||
}
|
||||
|
||||
// --- Custom Inline Content: WikiLink ---
|
||||
@@ -147,13 +150,43 @@ export const Editor = memo(function Editor({
|
||||
inspectorEntry, inspectorContent, allContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
}: EditorProps) {
|
||||
const [diffMode, setDiffMode] = useState(false)
|
||||
const [diffContent, setDiffContent] = useState<string | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
// Ref for vaultPath so the uploadFile closure always sees the latest value
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
vaultPathRef.current = vaultPath
|
||||
|
||||
// Single editor instance — reused across all tabs
|
||||
const editor = useCreateBlockNote({ schema })
|
||||
const editor = useCreateBlockNote({
|
||||
schema,
|
||||
uploadFile: async (file: File) => {
|
||||
if (isTauri() && vaultPathRef.current) {
|
||||
// Tauri mode: save to vault/attachments and return a stable asset URL
|
||||
const buf = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
|
||||
const base64 = btoa(binary)
|
||||
const savedPath = await invoke<string>('save_image', {
|
||||
vaultPath: vaultPathRef.current,
|
||||
filename: file.name,
|
||||
data: base64,
|
||||
})
|
||||
return convertFileSrc(savedPath)
|
||||
}
|
||||
// Browser dev mode: use data URL (survives reload, acceptable for dev)
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
},
|
||||
})
|
||||
// Cache parsed blocks per tab path for instant switching
|
||||
const tabCacheRef = useRef<Map<string, any[]>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
|
||||
@@ -1259,6 +1259,12 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
stop_reason: 'end_turn',
|
||||
}
|
||||
},
|
||||
save_image: (args: { vault_path?: string; filename: string; data: string }) => {
|
||||
// Return a plausible file path matching the real Rust backend behavior
|
||||
const vault = args.vault_path ?? '/Users/luca/Laputa'
|
||||
const timestamp = Date.now()
|
||||
return `${vault}/attachments/${timestamp}-${args.filename}`
|
||||
},
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user