From fb51276bc52aed3ebad57954f7ef07baa4debc15 Mon Sep 17 00:00:00 2001 From: Muse Date: Sun, 15 Nov 2026 09:16:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(s3):=20base64=20=E5=86=85=E5=B5=8C?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BA=B3=E5=85=A5=E6=9C=AA=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E4=B8=8E=E4=B8=8A=E4=BC=A0=EF=BC=88=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8C=87=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初版把 data: 一律排除在「未上传」外——未落盘文档粘贴的图、导入 文档自带的图都是 data:image/...;base64 形态,批量/右键/状态栏全 检测不到。现 isUploadableImageSrc = 本地文件引用 ∪ base64 内嵌图: base64 图走新命令 s3_upload_base64(总数 31 对账),前端剥前缀传 纯 base64、Rust 解码后与文件图共用同一条 PUT 通路;对象名按内容 哈希(FNV-1a)生成,同图同名幂等。未落盘文档粘贴图也挂上自动上传。 进度/报错展示名对 base64 图用其哈希名,不再漏出 base64 串。 验证:独立 crate 对模拟 S3 实测 base64 解码 PUT;vitest 136+2、构建通过。 --- docs/30-S3图片上传.md | 13 ++++++- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 11 ++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/s3.rs | 19 ++++++++++ src/App.tsx | 56 +++++++++++++++++----------- src/components/EditorContextMenu.tsx | 4 +- src/services/platform.ts | 5 +++ src/services/s3.ts | 47 ++++++++++++++++++++++- tests/s3.test.ts | 25 ++++++++++++- 11 files changed, 156 insertions(+), 27 deletions(-) diff --git a/docs/30-S3图片上传.md b/docs/30-S3图片上传.md index b1a6a65..78dbf67 100644 --- a/docs/30-S3图片上传.md +++ b/docs/30-S3图片上传.md @@ -24,8 +24,14 @@ endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload; 加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。 弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。 -- **「未上传」判定**:图片 src 不是 http(s)/data/blob 即本地引用 - (`isLocalImageSrc`)。上传成功后把编辑器里所有同 src 的图片节点 +- **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片 + (`data:image/...;base64,...`)都算未上传(`isUploadableImageSrc`); + http(s)/blob 不算。base64 形态来自未落盘文档的粘贴图与导入文档自带 + 图(2026-11-15 用户指正:初版把 data: 一律排除、检测不到)。base64 + 图没有文件可读,走单独的 `s3_upload_base64` 命令(命令总数 31): + 前端剥前缀传纯 base64,Rust 解码后与文件图共用同一条 PUT 通路; + 对象名按内容哈希生成(image-.<扩展名>),同图同名幂等。 + 上传成功后把编辑器里所有同 src 的图片节点 一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档 随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉 离线打开时的图,且用户可能还要本地备份)。 @@ -51,6 +57,9 @@ 20150830T123600Z)——算出的 Signature 与官方值 `5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7` 逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。 +- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节 + 与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在 + 发请求前就报错。 - **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地 TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、 x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 640a10f..b55d7c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2029,6 +2029,7 @@ dependencies = [ name = "new-markdown" version = "0.1.0" dependencies = [ + "base64 0.22.1", "encoding_rs", "hmac", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index feb822c..b0bee22 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,3 +22,4 @@ trash = "5" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } sha2 = "0.10" hmac = "0.12" +base64 = "0.22" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 96185ac..f05c7d6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -941,6 +941,17 @@ pub async fn s3_upload_image( .map_err(|e| format!("上传任务失败:{e}"))? } +#[tauri::command] +pub async fn s3_upload_base64( + config: crate::s3::S3Config, + base64: String, + file_name: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name)) + .await + .map_err(|e| format!("上传任务失败:{e}"))? +} + #[tauri::command] pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result { tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93498cb..58a06d5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { commands::git_set_remote, commands::git_sync_now, commands::s3_upload_image, + commands::s3_upload_base64, commands::s3_test_connection, commands::delete_to_trash, commands::db_recent_list, diff --git a/src-tauri/src/s3.rs b/src-tauri/src/s3.rs index 423bd14..502fd7b 100644 --- a/src-tauri/src/s3.rs +++ b/src-tauri/src/s3.rs @@ -161,6 +161,25 @@ pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result Result { + validate(cfg)?; + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(base64_str.trim()) + .map_err(|e| format!("图片 base64 解码失败:{e}"))?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err("图片超过 64MB,未上传。".into()); + } + upload_bytes(cfg, &bytes, file_name) +} + +fn upload_bytes(cfg: &S3Config, bytes: &[u8], file_name: &str) -> Result { + let bytes = bytes.to_vec(); let key = object_key(cfg, file_name); let (url, host, canonical_uri) = target(cfg, &key); let payload_hash = sha256_hex(&bytes); diff --git a/src/App.tsx b/src/App.tsx index 66f842e..d5f363b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,7 +29,7 @@ import { pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, - gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, type GitStatusInfo, + gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo, } from './services/platform' import * as store from './services/store' import { recoveryKey, planRecovery } from './utils/recovery' @@ -37,7 +37,7 @@ import { appWindowClaims, appWindowId } from './services/windowClaims' import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync' import { GitSyncModal } from './components/GitSyncModal' import { S3SettingsModal } from './components/S3SettingsModal' -import { DEFAULT_S3_SETTINGS, isS3Configured, isLocalImageSrc, resolveDocRelativePath, imageFileName, s3ConfigPayload, type S3Settings } from './services/s3' +import { DEFAULT_S3_SETTINGS, isS3Configured, isUploadableImageSrc, isDataImageSrc, parseDataImage, dataImageFileName, resolveDocRelativePath, imageFileName, imageDisplayName, s3ConfigPayload, type S3Settings } from './services/s3' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { DOC_THEMES, docThemeById } from './themes' @@ -1175,47 +1175,58 @@ export default function App() { const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null) const s3BusyRef = useRef(false) - /** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */ - const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise => { - const abs = resolveDocRelativePath(docPath, src) - return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(src)) + /** + * 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需 + * 文档落盘);本地文件图按 docPath + 相对 src 定位文件。 + */ + const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise => { + const cfg = s3ConfigPayload(uiRef.current.s3) + const data = parseDataImage(src) + if (data) { + const name = dataImageFileName(src) + if (!name) throw new Error('无法识别内嵌图片格式') + return s3UploadBase64(cfg, data.base64, name) + } + if (!docPath) throw new Error('文档尚未保存,无法定位本地图片文件') + return s3UploadImage(cfg, resolveDocRelativePath(docPath, src), imageFileName(src)) }, []) /** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */ const runSingleImageUpload = useCallback(async (src: string) => { const current = activeTab(stateRef.current) - if (!current?.path) { setError('请先保存文档,再上传图片。'); return } + if (!current) return + if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return } if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return } flash('正在上传图片…') try { - const url = await uploadLocalImage(current.path, src) + const url = await uploadImageSrc(current.path, src) paneApisRef.current.get(current.id)?.replaceImageSrc(src, url) flash('图片已上传到 S3,引用已更新') } catch (e) { setError(e instanceof Error ? e.message : String(e)) } - }, [flash, uploadLocalImage]) + }, [flash, uploadImageSrc]) /** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */ const runBatchImageUpload = async () => { - if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return } + if (!tab) return if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return } if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return } if (s3BusyRef.current) return const api = paneApisRef.current.get(tab.id) if (!api) return - const locals = [...new Set(api.listImages().filter(isLocalImageSrc))] - if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return } + const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))] + if (locals.length === 0) { flash('本文档没有未上传的图片'); return } const docPath = tab.path s3BusyRef.current = true setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false }) const errors: string[] = [] let done = 0 for (const src of locals) { - setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr)) + setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr)) try { - const url = await uploadLocalImage(docPath, src) + const url = await uploadImageSrc(docPath, src) api.replaceImageSrc(src, url) } catch (e) { - errors.push(`${imageFileName(src)}:${e instanceof Error ? e.message : String(e)}`) + errors.push(`${imageDisplayName(src)}:${e instanceof Error ? e.message : String(e)}`) } done += 1 setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr)) @@ -1229,12 +1240,12 @@ export default function App() { * 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则 * 保留本地引用并提示可用批量上传同步,不静默。 */ - const autoUploadSwap = useCallback((tabId: string, docPath: string, relSrc: string) => { + const autoUploadSwap = useCallback((tabId: string, docPath: string | null, relSrc: string) => { const settings = uiRef.current.s3 - if (!settings.autoUpload || !isS3Configured(settings)) return + if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return void (async () => { try { - const url = await s3UploadImage(s3ConfigPayload(settings), resolveDocRelativePath(docPath, relSrc), imageFileName(relSrc)) + const url = await uploadImageSrc(docPath, relSrc) let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0 if (n === 0) { await new Promise((r) => setTimeout(r, 900)) @@ -1245,7 +1256,7 @@ export default function App() { flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`) } })() - }, [flash]) + }, [flash, uploadImageSrc]) /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ const handleImageFile = useCallback(async (file: File): Promise => { @@ -1257,12 +1268,15 @@ export default function App() { autoUploadSwap(current.id, current.path, rel) return rel } - return new Promise((resolve) => { + const dataUrl = await new Promise((resolve) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result)) reader.onerror = () => resolve(URL.createObjectURL(file)) reader.readAsDataURL(file) }) + // 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换 + if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl) + return dataUrl }, [autoUploadSwap]) const insertImage = useCallback(async () => { @@ -1508,7 +1522,7 @@ export default function App() { const found = new Set() const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g let m: RegExpExecArray | null - while ((m = re.exec(tab.content))) if (isLocalImageSrc(m[1])) found.add(m[1]) + while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1]) return found.size }, [tab?.id, tab?.content, sourceMode]) diff --git a/src/components/EditorContextMenu.tsx b/src/components/EditorContextMenu.tsx index d64564d..022fbe3 100644 --- a/src/components/EditorContextMenu.tsx +++ b/src/components/EditorContextMenu.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react' import { useDismiss } from '../hooks/useDismiss' import { clampMenuPosition } from '../utils/menuPos' -import { isLocalImageSrc } from '../services/s3' +import { isUploadableImageSrc } from '../services/s3' export type EditorMenuAction = | 'cut' | 'copy' | 'paste' | 'selectAll' @@ -29,7 +29,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source const ref = useRef(null) useDismiss(ref, true, onClose) const act = (a: EditorMenuAction) => { onClose(); onAction(a) } - const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc)) + const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc)) // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0)) return ( diff --git a/src/services/platform.ts b/src/services/platform.ts index b4afce3..5d9dcc1 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -366,6 +366,11 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f return invoke('s3_upload_image', { config, filePath, fileName }) } +/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */ +export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise { + return invoke('s3_upload_base64', { config, base64, fileName }) +} + /** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */ export async function s3TestConnection(config: S3ConfigPayload): Promise { return invoke('s3_test_connection', { config }) diff --git a/src/services/s3.ts b/src/services/s3.ts index 1e03c91..bb2dd0c 100644 --- a/src/services/s3.ts +++ b/src/services/s3.ts @@ -23,13 +23,53 @@ export function isS3Configured(s: S3Settings): boolean { return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim()) } -/** 本地图片 = 文档里的相对/本地引用;http(s)/data/blob 已经是网络或内嵌资源,不需要上传。 */ +/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */ export function isLocalImageSrc(src: string): boolean { const t = src.trim() if (!t) return false return !/^(https?:|data:|blob:)/i.test(t) } +/** + * 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、 + * 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节, + * 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。 + */ +export function parseDataImage(src: string): { mime: string; base64: string } | null { + const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim()) + if (!m) return null + return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') } +} + +export function isDataImageSrc(src: string): boolean { + return parseDataImage(src) !== null +} + +/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */ +export function isUploadableImageSrc(src: string): boolean { + return isLocalImageSrc(src) || isDataImageSrc(src) +} + +/** + * base64 图片的对象文件名:image-<内容哈希>.<扩展名>。 + * 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。 + */ +export function dataImageFileName(src: string): string | null { + const parsed = parseDataImage(src) + if (!parsed) return null + let ext = parsed.mime.split('/')[1] ?? 'png' + ext = ext.split('+')[0] + if (ext === 'jpeg') ext = 'jpg' + if (!/^[a-z0-9]+$/.test(ext)) ext = 'png' + let h = 0x811c9dc5 + const b64 = parsed.base64 + for (let i = 0; i < b64.length; i++) { + h ^= b64.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}` +} + /** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */ export function s3ConfigPayload(s: S3Settings): S3ConfigPayload { return { @@ -68,6 +108,11 @@ function safeDecode(s: string): string { try { return decodeURIComponent(s) } catch { return s } } +/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */ +export function imageDisplayName(src: string): string { + return dataImageFileName(src) ?? imageFileName(src) +} + /** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */ export function imageFileName(src: string): string { const clean = safeDecode(src).replace(/\\/g, '/') diff --git a/tests/s3.test.ts b/tests/s3.test.ts index 2089419..9260431 100644 --- a/tests/s3.test.ts +++ b/tests/s3.test.ts @@ -1,6 +1,6 @@ // S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。 import { describe, it, expect } from 'vitest' -import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3' +import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, isUploadableImageSrc, parseDataImage, dataImageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3' describe('isLocalImageSrc', () => { it('相对路径算本地;http/data/blob 不算', () => { @@ -14,6 +14,29 @@ describe('isLocalImageSrc', () => { }) }) +describe('base64 内嵌图片', () => { + const png = 'data:image/png;base64,UE5HREFUQS0xMjM=' + it('data:image 算未上传;普通 data: 与网络图不算', () => { + expect(isUploadableImageSrc(png)).toBe(true) + expect(isUploadableImageSrc('data:text/plain;base64,xx')).toBe(false) + expect(isUploadableImageSrc('https://x/a.png')).toBe(false) + }) + it('解析出 mime 与纯 base64(剥空白)', () => { + expect(parseDataImage(png)).toEqual({ mime: 'image/png', base64: 'UE5HREFUQS0xMjM=' }) + expect(parseDataImage('data:image/jpeg;base64,AA\nBB')).toEqual({ mime: 'image/jpeg', base64: 'AABB' }) + expect(parseDataImage('doc.assets/a.png')).toBeNull() + }) + it('对象名按内容哈希:同图同名、异图异名,jpeg 归一为 jpg', () => { + const a = dataImageFileName(png) + expect(a).toMatch(/^image-[0-9a-f]{8}\.png$/) + expect(dataImageFileName(png)).toBe(a) + expect(dataImageFileName('data:image/png;base64,QUJD')).not.toBe(a) + expect(dataImageFileName('data:image/jpeg;base64,QUJD')?.endsWith('.jpg')).toBe(true) + expect(dataImageFileName('data:image/svg+xml;base64,QUJD')?.endsWith('.svg')).toBe(true) + expect(dataImageFileName('doc.assets/a.png')).toBeNull() + }) +}) + describe('isS3Configured', () => { it('四项必填齐才算配置过', () => { expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)