feat(s3): base64 内嵌图片纳入未上传检测与上传(用户指正)

初版把 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、构建通过。
This commit is contained in:
Muse
2026-11-15 09:16:18 +08:00
parent b3c22fd597
commit fb51276bc5
11 changed files with 156 additions and 27 deletions

View File

@@ -24,8 +24,14 @@
endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload; endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload;
加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。 加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。
弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。 弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。
- **「未上传」判定**:图片 src 不是 http(s)/data/blob 即本地引用 - **「未上传」判定**:本地文件引用(相对路径)或内嵌 base64 图片
(`isLocalImageSrc`)。上传成功后把编辑器里所有同 src 的图片节点 (`data:image/...;base64,...`)都算未上传(`isUploadableImageSrc`);
http(s)/blob 不算。base64 形态来自未落盘文档的粘贴图与导入文档自带
图(2026-11-15 用户指正:初版把 data: 一律排除、检测不到)。base64
图没有文件可读,走单独的 `s3_upload_base64` 命令(命令总数 31):
前端剥前缀传纯 base64,Rust 解码后与文件图共用同一条 PUT 通路;
对象名按内容哈希生成(image-<FNV-1a>.<扩展名>),同图同名幂等。
上传成功后把编辑器里所有同 src 的图片节点
一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档 一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档
随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉 随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉
离线打开时的图,且用户可能还要本地备份)。 离线打开时的图,且用户可能还要本地备份)。
@@ -51,6 +57,9 @@
20150830T123600Z)——算出的 Signature 与官方值 20150830T123600Z)——算出的 Signature 与官方值
`5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7` `5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7`
逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。 逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。
- **base64 通路**:同一独立 crate 里实测 upload_base64:解码后的字节
与原文一致地 PUT 到模拟 S3、Content-Type 按扩展名、非法 base64 在
发请求前就报错。
- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地 - **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地
TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、 TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、
x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、 x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、

1
src-tauri/Cargo.lock generated
View File

@@ -2029,6 +2029,7 @@ dependencies = [
name = "new-markdown" name = "new-markdown"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"encoding_rs", "encoding_rs",
"hmac", "hmac",
"reqwest 0.12.28", "reqwest 0.12.28",

View File

@@ -22,3 +22,4 @@ trash = "5"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
sha2 = "0.10" sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
base64 = "0.22"

View File

@@ -941,6 +941,17 @@ pub async fn s3_upload_image(
.map_err(|e| format!("上传任务失败:{e}"))? .map_err(|e| format!("上传任务失败:{e}"))?
} }
#[tauri::command]
pub async fn s3_upload_base64(
config: crate::s3::S3Config,
base64: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::upload_base64(&config, &base64, &file_name))
.await
.map_err(|e| format!("上传任务失败:{e}"))?
}
#[tauri::command] #[tauri::command]
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> { pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config)) tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))

View File

@@ -68,6 +68,7 @@ pub fn run() {
commands::git_set_remote, commands::git_set_remote,
commands::git_sync_now, commands::git_sync_now,
commands::s3_upload_image, commands::s3_upload_image,
commands::s3_upload_base64,
commands::s3_test_connection, commands::s3_test_connection,
commands::delete_to_trash, commands::delete_to_trash,
commands::db_recent_list, commands::db_recent_list,

View File

@@ -161,6 +161,25 @@ pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String
return Err("图片超过 64MB,未上传。".into()); return Err("图片超过 64MB,未上传。".into());
} }
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?; let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
upload_bytes(cfg, &bytes, file_name)
}
/// 上传文档内嵌的 base64 图片(data:image/...;base64 数据,前端已剥掉前缀)。
/// 未落盘文档粘贴的图片只有这种形态,没有文件可读。
pub fn upload_base64(cfg: &S3Config, base64_str: &str, file_name: &str) -> Result<String, String> {
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<String, String> {
let bytes = bytes.to_vec();
let key = object_key(cfg, file_name); let key = object_key(cfg, file_name);
let (url, host, canonical_uri) = target(cfg, &key); let (url, host, canonical_uri) = target(cfg, &key);
let payload_hash = sha256_hex(&bytes); let payload_hash = sha256_hex(&bytes);

View File

@@ -29,7 +29,7 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, type GitStatusInfo, gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery' 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 { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
import { GitSyncModal } from './components/GitSyncModal' import { GitSyncModal } from './components/GitSyncModal'
import { S3SettingsModal } from './components/S3SettingsModal' 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 { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES, docThemeById } from './themes' 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 [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
const s3BusyRef = useRef(false) const s3BusyRef = useRef(false)
/** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */ /**
const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise<string> => { * 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需
const abs = resolveDocRelativePath(docPath, src) * 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(src)) */
const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise<string> => {
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 地址。 */ /** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
const runSingleImageUpload = useCallback(async (src: string) => { const runSingleImageUpload = useCallback(async (src: string) => {
const current = activeTab(stateRef.current) 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 } if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
flash('正在上传图片…') flash('正在上传图片…')
try { try {
const url = await uploadLocalImage(current.path, src) const url = await uploadImageSrc(current.path, src)
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url) paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
flash('图片已上传到 S3,引用已更新') flash('图片已上传到 S3,引用已更新')
} catch (e) { setError(e instanceof Error ? e.message : String(e)) } } catch (e) { setError(e instanceof Error ? e.message : String(e)) }
}, [flash, uploadLocalImage]) }, [flash, uploadImageSrc])
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */ /** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
const runBatchImageUpload = async () => { const runBatchImageUpload = async () => {
if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return } if (!tab) return
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return } if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return } if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
if (s3BusyRef.current) return if (s3BusyRef.current) return
const api = paneApisRef.current.get(tab.id) const api = paneApisRef.current.get(tab.id)
if (!api) return if (!api) return
const locals = [...new Set(api.listImages().filter(isLocalImageSrc))] const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return } if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
const docPath = tab.path const docPath = tab.path
s3BusyRef.current = true s3BusyRef.current = true
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false }) setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
const errors: string[] = [] const errors: string[] = []
let done = 0 let done = 0
for (const src of locals) { for (const src of locals) {
setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr)) setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
try { try {
const url = await uploadLocalImage(docPath, src) const url = await uploadImageSrc(docPath, src)
api.replaceImageSrc(src, url) api.replaceImageSrc(src, url)
} catch (e) { } 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 done += 1
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr)) setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
@@ -1229,12 +1240,12 @@ export default function App() {
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则 * 粘贴建节点是异步的,第一次替换可能扑空,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 const settings = uiRef.current.s3
if (!settings.autoUpload || !isS3Configured(settings)) return if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
void (async () => { void (async () => {
try { 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 let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
if (n === 0) { if (n === 0) {
await new Promise((r) => setTimeout(r, 900)) await new Promise((r) => setTimeout(r, 900))
@@ -1245,7 +1256,7 @@ export default function App() {
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`) flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
} }
})() })()
}, [flash]) }, [flash, uploadImageSrc])
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => { const handleImageFile = useCallback(async (file: File): Promise<string> => {
@@ -1257,12 +1268,15 @@ export default function App() {
autoUploadSwap(current.id, current.path, rel) autoUploadSwap(current.id, current.path, rel)
return rel return rel
} }
return new Promise((resolve) => { const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => resolve(String(reader.result)) reader.onload = () => resolve(String(reader.result))
reader.onerror = () => resolve(URL.createObjectURL(file)) reader.onerror = () => resolve(URL.createObjectURL(file))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
// 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
return dataUrl
}, [autoUploadSwap]) }, [autoUploadSwap])
const insertImage = useCallback(async () => { const insertImage = useCallback(async () => {
@@ -1508,7 +1522,7 @@ export default function App() {
const found = new Set<string>() const found = new Set<string>()
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let m: RegExpExecArray | null 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 return found.size
}, [tab?.id, tab?.content, sourceMode]) }, [tab?.id, tab?.content, sourceMode])

View File

@@ -1,7 +1,7 @@
import { useRef } from 'react' import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
import { clampMenuPosition } from '../utils/menuPos' import { clampMenuPosition } from '../utils/menuPos'
import { isLocalImageSrc } from '../services/s3' import { isUploadableImageSrc } from '../services/s3'
export type EditorMenuAction = export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll' | 'cut' | 'copy' | 'paste' | 'selectAll'
@@ -29,7 +29,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose) useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) } const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc)) const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0)) const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
return ( return (

View File

@@ -366,6 +366,11 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
return invoke<string>('s3_upload_image', { config, filePath, fileName }) return invoke<string>('s3_upload_image', { config, filePath, fileName })
} }
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })
}
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */ /** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> { export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
return invoke<string>('s3_test_connection', { config }) return invoke<string>('s3_test_connection', { config })

View File

@@ -23,13 +23,53 @@ export function isS3Configured(s: S3Settings): boolean {
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim()) 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 { export function isLocalImageSrc(src: string): boolean {
const t = src.trim() const t = src.trim()
if (!t) return false if (!t) return false
return !/^(https?:|data:|blob:)/i.test(t) 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)。 */ /** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload { export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
return { return {
@@ -68,6 +108,11 @@ function safeDecode(s: string): string {
try { return decodeURIComponent(s) } catch { return s } try { return decodeURIComponent(s) } catch { return s }
} }
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
export function imageDisplayName(src: string): string {
return dataImageFileName(src) ?? imageFileName(src)
}
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */ /** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
export function imageFileName(src: string): string { export function imageFileName(src: string): string {
const clean = safeDecode(src).replace(/\\/g, '/') const clean = safeDecode(src).replace(/\\/g, '/')

View File

@@ -1,6 +1,6 @@
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。 // S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
import { describe, it, expect } from 'vitest' 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', () => { describe('isLocalImageSrc', () => {
it('相对路径算本地;http/data/blob 不算', () => { 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', () => { describe('isS3Configured', () => {
it('四项必填齐才算配置过', () => { it('四项必填齐才算配置过', () => {
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false) expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)