diff --git a/internal/api/api.go b/internal/api/api.go index fc878cf..69e9472 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -85,6 +85,7 @@ func (s *Server) routes() []route { {"POST /api/v1/move", authed, s.move}, {"POST /api/v1/folders", authed, s.mkdir}, {"POST /api/v1/assets", authed, s.uploadAsset}, + {"POST /api/v1/assets/import", authed, s.importAsset}, {"GET /api/v1/raw/{path...}", authed, s.raw}, {"GET /api/v1/trash", authed, s.trashList}, {"POST /api/v1/trash/{id}/restore", authed, s.trashRestore}, diff --git a/internal/api/import.go b/internal/api/import.go new file mode 100644 index 0000000..531037f --- /dev/null +++ b/internal/api/import.go @@ -0,0 +1,229 @@ +package api + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/netip" + "net/url" + "syscall" + "time" + + "rawmark/internal/vault" +) + +var ( + errImageAddress = errors.New("图片地址必须是公网 HTTP 或 HTTPS 地址") + errImageType = errors.New("仅支持导入 PNG、JPEG、GIF 和 WebP 图片") +) + +var blockedImageNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), +} + +func publicImageIP(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return false + } + // 不接受 IPv6 地址转换前缀,避免其中嵌入的 IPv4 地址访问内网。 + if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) { + return false + } + for _, prefix := range blockedImageNetworks { + if prefix.Contains(ip) { + return false + } + } + return true +} + +func checkImageURL(u *url.URL) error { + if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil { + return errImageAddress + } + return nil +} + +var remoteImageClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + // 不使用环境代理,确保每个实际连接都经过目标地址校验。 + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip, err := netip.ParseAddr(host) + if err != nil || !publicImageIP(ip) { + return errImageAddress + } + local, err := net.InterfaceAddrs() + if err != nil { + return err + } + for _, addr := range local { + prefix, err := netip.ParsePrefix(addr.String()) + if err != nil { + return err + } + if ip.Unmap() == prefix.Addr().Unmap() { + return errImageAddress + } + } + return nil + }, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + IdleConnTimeout: 30 * time.Second, + ForceAttemptHTTP2: true, + }, + CheckRedirect: func(r *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("图片来源重定向次数过多") + } + r.Header.Del("Referer") + return checkImageURL(r.URL) + }, +} + +// readImageDownload 按实际文件签名确定类型,不信任来源的扩展名或 Content-Type。 +func readImageDownload(body io.Reader) ([]byte, string, error) { + data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1)) + if err != nil { + return nil, "", err + } + if int64(len(data)) > maxAssetBytes { + return nil, "", vault.ErrTooLarge + } + var ext string + switch http.DetectContentType(data) { + case "image/png": + ext = ".png" + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + default: + return nil, "", errImageType + } + return data, ext, nil +} + +func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { + var body struct{ URL string } + if !readJSON(w, r, &body) { + return + } + u, err := url.Parse(body.URL) + if err != nil || checkImageURL(u) != nil { + writeError(w, 400, errImageAddress.Error()) + return + } + note := r.URL.Query().Get("note") + if !vault.IsNote(note) { + writeError(w, 400, "所属笔记路径无效") + return + } + if _, err := s.vault.Stat(note); err != nil { + vaultError(w, r, err) + return + } + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + timer := time.NewTimer(time.Second) + select { + case <-r.Context().Done(): + timer.Stop() + return + case <-timer.C: + } + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil) + if err != nil { + writeError(w, 400, errImageAddress.Error()) + return + } + res, err := remoteImageClient.Do(req) + if err != nil { + var timeout net.Error + if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil { + continue + } + if errors.Is(err, errImageAddress) { + writeError(w, 400, errImageAddress.Error()) + return + } + // url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。 + var urlError *url.Error + if errors.As(err, &urlError) { + err = urlError.Err + } + slog.Error("下载图片失败", "err", err) + writeError(w, 502, "下载图片失败") + return + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + res.Body.Close() + if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) { + continue + } + writeError(w, 502, fmt.Sprintf("图片来源返回 HTTP %d", res.StatusCode)) + return + } + data, ext, err := readImageDownload(res.Body) + res.Body.Close() + if err != nil { + var timeout net.Error + if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil { + continue + } + if errors.Is(err, vault.ErrTooLarge) { + vaultError(w, r, err) + } else if errors.Is(err, errImageType) { + writeError(w, 422, err.Error()) + } else { + slog.Error("读取下载图片失败", "err", err) + writeError(w, 502, "读取下载图片失败") + } + return + } + // 下载期间笔记可能被外部程序移动或删除;不能在旧路径下创建孤立资源。 + info, err := s.vault.Stat(note) + if err != nil { + vaultError(w, r, err) + return + } + if info.IsDir() { + writeError(w, 400, "所属路径必须是笔记文件") + return + } + link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes) + if err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, map[string]string{"link": link}) + return + } +} diff --git a/internal/api/import_test.go b/internal/api/import_test.go new file mode 100644 index 0000000..606ac56 --- /dev/null +++ b/internal/api/import_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "rawmark/internal/vault" +) + +func TestImportImageAddressBoundary(t *testing.T) { + for _, address := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.100.100.200", "0.1.2.3", "198.18.0.1", "224.0.0.1", "240.0.0.1", "::", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "64:ff9b::7f00:1", "2002:7f00:1::", "2001::1", "2001:db8::1", "3fff::1"} { + if publicImageIP(netip.MustParseAddr(address)) { + t.Errorf("不应接受内网或特殊用途地址 %s", address) + } + } + for _, address := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} { + if !publicImageIP(netip.MustParseAddr(address)) { + t.Errorf("应接受公网地址 %s", address) + } + } + f := newFixture(t) + f.write("n.md", "正文") + for _, source := range []string{"file:///etc/passwd", "ftp://example.com/a.png", "https://user:password@example.com/a.png", "http://127.0.0.1/a.png", "http://[::1]/a.png", "http://localhost/a.png"} { + body, err := json.Marshal(map[string]string{"url": source}) + if err != nil { + t.Fatal(err) + } + if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=n.md", string(body), nil); code != 400 { + t.Fatalf("应拒绝非公网图片来源,得到 %d", code) + } + } + if code := f.status(newClient(), "POST", "/api/v1/assets/import?note=n.md", `{"url":"https://example.com/a.png"}`, nil); code != 401 { + t.Fatalf("未登录不允许下载: %d", code) + } +} + +func TestImportImageDownloadAndRedirects(t *testing.T) { + png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aT6sAAAAASUVORK5CYII=") + if err != nil { + t.Fatal(err) + } + attempts := map[string]int{} + var ownerRoot string + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts[r.URL.Path]++ + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" || r.Header.Get("Referer") != "" { + t.Error("下载请求泄露了凭据或来源") + } + switch r.URL.Path { + case "/private": + http.Redirect(w, r, "http://127.0.0.1/private.png", http.StatusFound) + case "/scheme": + http.Redirect(w, r, "file:///etc/passwd", http.StatusFound) + case "/redirect": + http.Redirect(w, r, "/image", http.StatusFound) + case "/loop": + http.Redirect(w, r, "/loop", http.StatusFound) + case "/retry": + if attempts[r.URL.Path] == 1 { + w.WriteHeader(503) + return + } + w.Write(png) + case "/failed": + w.WriteHeader(429) + case "/missing": + w.WriteHeader(404) + case "/renamed": + if err := os.Rename(filepath.Join(ownerRoot, "moving.md"), filepath.Join(ownerRoot, "moved.md")); err != nil { + t.Error(err) + } + w.Write(png) + case "/html": + w.Header().Set("Content-Type", "image/png") + io.WriteString(w, "") + default: + w.Header().Set("Content-Type", "text/plain") + w.Write(png) + } + })) + defer source.Close() + original := remoteImageClient + transport := original.Transport.(*http.Transport).Clone() + dial := transport.DialContext + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + // 只把固定的测试来源映射到本地真实 HTTP 服务,其他目标仍走生产地址校验。 + if address == "images.example:80" { + return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String()) + } + return dial(ctx, network, address) + } + client := *original + client.Transport = transport + remoteImageClient = &client + t.Cleanup(func() { transport.CloseIdleConnections(); remoteImageClient = original }) + f := newFixture(t) + ownerRoot = f.root + f.write("n.md", "正文") + for _, route := range []string{"/image", "/redirect", "/retry"} { + body := `{"url":"http://images.example` + route + `"}` + result := decode[map[string]string](t, f.do(f.admin, "POST", "/api/v1/assets/import?note=n.md", body, nil)) + data, err := os.ReadFile(filepath.Join(f.root, filepath.FromSlash(result["link"]))) + if err != nil || !bytes.Equal(data, png) || !strings.HasSuffix(result["link"], ".png") { + t.Fatalf("导入内容、类型或路径不符: %v", err) + } + } + for route, expected := range map[string]int{"/private": 400, "/scheme": 400, "/loop": 502, "/html": 422, "/missing": 502, "/failed": 502} { + body := `{"url":"http://images.example` + route + `"}` + if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=n.md", body, nil); code != expected { + t.Errorf("%s 应返回 %d,得到 %d", route, expected, code) + } + } + if attempts["/retry"] != 2 || attempts["/failed"] != 2 || attempts["/missing"] != 1 || attempts["/loop"] != 5 { + t.Fatalf("重试或重定向没有遵守上限: %v", attempts) + } + f.write("moving.md", "正文") + if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=moving.md", `{"url":"http://images.example/renamed"}`, nil); code != 404 { + t.Fatalf("下载期间移动的笔记不应写入旧路径: %d", code) + } + if _, err := os.Stat(filepath.Join(f.root, "moving.assets")); !os.IsNotExist(err) { + t.Fatalf("不应在旧位置创建资源: %v", err) + } +} + +func TestReadImageDownloadSizeLimit(t *testing.T) { + _, _, err := readImageDownload(bytes.NewReader(make([]byte, maxAssetBytes+1))) + if !errors.Is(err, vault.ErrTooLarge) { + t.Fatalf("超限图片应被拒绝: %v", err) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index aeaa556..08719cb 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -790,6 +790,111 @@ } } }, + "/assets/import": { + "post": { + "summary": "下载公网图片到笔记的 .assets 目录", + "tags": [ + "图片" + ], + "description": "仅接受公网 HTTP/HTTPS 地址,实际连接及重定向均校验;不携带登录凭据或使用环境代理。重定向链最多 5 个请求、2 次下载尝试,单次超时 10 秒;仅超时、429 和 5xx 重试。支持 PNG/JPEG/GIF/WebP,按文件签名识别,最大 50 MB。", + "parameters": [ + { + "name": "note", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "相对笔记所在目录的图片链接", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "link": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "来源地址或笔记路径无效,或目标不是公网", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "所属笔记不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "图片超过 50 MB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "不支持的图片类型", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "来源请求失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/raw/{path}": { "parameters": [ { diff --git a/web/package-lock.json b/web/package-lock.json index ae80e87..3778a5b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,9 +18,11 @@ "katex": "^0.18.11", "mermaid": "^12.2.0", "shiki": "^4.5.0", + "turndown": "^7.2.4", "vue": "^3.5.44" }, "devDependencies": { + "@types/turndown": "^5.0.6", "@vitejs/plugin-vue": "^6.0.10", "commonmark-spec": "^0.31.2", "happy-dom": "^21.0.3", @@ -398,6 +400,12 @@ "node": ">=22.12.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@oxc-project/types": { "version": "0.154.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", @@ -1107,6 +1115,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3099,6 +3114,19 @@ "node": ">=6.10" } }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/web/package.json b/web/package.json index 16b1712..82fcce9 100644 --- a/web/package.json +++ b/web/package.json @@ -21,9 +21,11 @@ "katex": "^0.18.11", "mermaid": "^12.2.0", "shiki": "^4.5.0", + "turndown": "^7.2.4", "vue": "^3.5.44" }, "devDependencies": { + "@types/turndown": "^5.0.6", "@vitejs/plugin-vue": "^6.0.10", "commonmark-spec": "^0.31.2", "happy-dom": "^21.0.3", diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue index 27c96f6..8afcfae 100644 --- a/web/src/app/Workspace.vue +++ b/web/src/app/Workspace.vue @@ -15,7 +15,7 @@ import { noteTitle } from './paths' import { SIDEBAR_WIDTH, settings, updateSettings } from './settings' import Sidebar from './Sidebar.vue' import TabBar from './TabBar.vue' -import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace' +import { activeTab, flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace' const emit = defineEmits<{ logout: [] }>() const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark')) @@ -43,6 +43,10 @@ function startResize(e: PointerEvent) { async function logout() { await flushAll() + if (!readyToReload()) { + toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出') + return + } try { await api.logout() } catch { diff --git a/web/src/app/api.ts b/web/src/app/api.ts index c4250d9..4c3d72d 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -159,6 +159,9 @@ export const api = { return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link }, + importImage: async (note: string, url: string): Promise => + (await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link, + shares: async (path?: string) => (await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares, // expiresIn 为有效时长(秒),0 为永久 diff --git a/web/src/app/workspace.test.ts b/web/src/app/workspace.test.ts index 6ca742a..1de3954 100644 --- a/web/src/app/workspace.test.ts +++ b/web/src/app/workspace.test.ts @@ -6,7 +6,7 @@ import { api, ApiError } from './api' import { drafts, type Draft } from './drafts' import { connectEvents } from './events' import { pluginEvents } from '../plugins/registry' -import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, ws } from './workspace' +import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace' vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) })) @@ -136,6 +136,32 @@ describe('保存与关闭', () => { await closeTab('race.md') }) + it('网页图片导入期间切换标签,转换内容仍保存到原笔记', async () => { + const v = await edit('网页.md') + vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path) + const contents = new Map() + vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' }) + let finish!: (link: string) => void + vi.spyOn(api, 'importImage').mockImplementation(() => new Promise((resolve) => { finish = resolve })) + const data = new DataTransfer() + data.setData('text/plain', '网页标题') + data.setData('text/html', '

网页标题

') + v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + expect(await closeTab('网页.md')).toBe(false) + await openNote('other.md') + storeViewState('网页.md', v.state) + v.setState(editorStateOf('other.md')!) + finish('网页.assets/hash.png') + await vi.waitFor(() => expect(editorStateOf('网页.md')!.doc.toString()).toContain('网页.assets/hash.png')) + await flush('网页.md') + expect(contents.get('网页.md')).toContain('# 网页标题') + expect(contents.get('网页.md')).toContain('网页.assets/hash.png') + expect(v.state.doc.toString()).toBe('原文') + await closeTab('网页.md') + await closeTab('other.md') + }) + it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => { const v = await edit('image.md') vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index df9bce2..b00e00d 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -188,21 +188,23 @@ function buildState(path: string, doc: string): EditorState { }), pasteImages({ upload: (file) => api.upload(ref.path, file), - replace: (text, replacement) => { + importImage: (url) => api.importImage(ref.path, url), + update: (change) => { const shown = view?.state.facet(notePath) === ref const state = shown ? view!.state : editorStates.get(ref.path) - if (!state) throw new Error('上传所属笔记的编辑状态不存在') - const at = state.doc.toString().indexOf(text) - if (at < 0) return // 用户已删除占位 - const spec = { changes: { from: at, to: at + text.length, insert: replacement } } + if (!state) throw new Error('粘贴所属笔记的编辑状态不存在') + const spec = change(state) + if (!spec) return if (shown) view!.dispatch(spec) else { - editorStates.set(ref.path, state.update(spec).state) - scheduleSave(ref.path) + const transaction = state.update(spec) + editorStates.set(ref.path, transaction.state) + if (transaction.docChanged) scheduleSave(ref.path) } }, - onError: (err) => toast('图片上传失败:' + errorText(err)), + onError: (err) => toast('粘贴或图片导入失败:' + (err instanceof Error ? err.message : errorText(err))), onPending: (d) => { + if (d === 1 && slot(ref.path).uploads === 0) toast('正在处理粘贴或图片导入…') slot(ref.path).uploads += d }, }), @@ -911,6 +913,9 @@ export async function startWorkspace() { if (document.visibilityState === 'hidden') void flushAll(true) }) window.addEventListener('pagehide', () => void flushAll(true)) + window.addEventListener('beforeunload', (event) => { + if (!readyToReload()) event.preventDefault() + }) window.addEventListener('keydown', handleShortcut) // 软键盘弹出、编辑区变矮后,让光标保持可见 trackVisualViewport(() => { diff --git a/web/src/editor/html-paste.ts b/web/src/editor/html-paste.ts new file mode 100644 index 0000000..d8e05a9 --- /dev/null +++ b/web/src/editor/html-paste.ts @@ -0,0 +1,74 @@ +import TurndownService from 'turndown' + +export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise, onError: (error: unknown) => void): Promise { + // template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。 + const template = document.createElement('template') + template.innerHTML = html + const fragment = template.content + const base = fragment.querySelector('base[href]')?.getAttribute('href') + fragment.querySelectorAll('script,style,iframe,object,embed,template,meta,link,base').forEach((node) => node.remove()) + if (base) { + for (const node of fragment.querySelectorAll('a[href],img[src]')) { + const attribute = node.tagName === 'A' ? 'href' : 'src' + try { node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href) } + catch (error) { onError(new Error('来源基址无效,已保留原链接', { cause: error })) } + } + } + + const images = new Map() + for (const image of fragment.querySelectorAll('img')) { + let src = image.getAttribute('src') ?? '' + if (src.startsWith('//')) { src = 'https:' + src; image.setAttribute('src', src) } + if (!/^https?:\/\//i.test(src)) continue + const group = images.get(src) ?? [] + group.push(image) + images.set(src, group) + } + const sources = [...images] + // 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。 + for (let offset = 0; offset < sources.length; offset += 3) { + await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => { + try { + const link = await importImage(src) + for (const node of nodes) node.setAttribute('src', link) + } catch (error) { onError(error) } + })) + } + + const service = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' }) + service.addRule('strikethrough', { filter: (node) => ['DEL', 'S', 'STRIKE'].includes(node.tagName), replacement: (content) => '~~' + content + '~~' }) + service.addRule('table', { + filter: 'table', + replacement: (_content, node) => { + const rows = [...node.querySelectorAll('tr')].filter((row) => row.closest('table') === node).map((row) => + [...row.cells].map((cell) => + service.turndown(cell).replace(/\|/g, '\\|').replace(/\n+/g, ' '), + ), + ) + if (!rows.length) return '' + const columns = Math.max(...rows.map((row) => row.length)) + if (!columns) return '' + const line = (cells: string[]) => '| ' + Array.from({ length: columns }, (_, index) => cells[index] ?? '').join(' | ') + ' |' + return '\n\n' + [line(rows[0]), line(Array(columns).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n' + }, + }) + // 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。 + service.addRule('link', { + filter: 'a', + replacement: (content, node) => { + const href = node.getAttribute('href') ?? '' + return href && !/^[\s\u0000-\u0020]*(?:javascript|vbscript|data|file):/i.test(href.replace(/[\u0000-\u0020]/g, '')) + ? '[' + content + '](<' + href.replace(//g, '%3E') + '>)' : content + }, + }) + service.addRule('image', { + filter: 'img', + replacement: (_content, node) => { + const src = node.getAttribute('src') ?? '' + const alt = (node.getAttribute('alt') ?? '').replace(/[\[\]\\]/g, '\\$&').replace(/\n/g, ' ') + return src && !/^(?:javascript|vbscript|file):/i.test(src.replace(/[\u0000-\u0020]/g, '')) + ? '![' + alt + '](<' + src.replace(//g, '%3E') + '>)' : alt + }, + }) + return service.turndown(fragment) +} diff --git a/web/src/editor/paste.test.ts b/web/src/editor/paste.test.ts new file mode 100644 index 0000000..d697a89 --- /dev/null +++ b/web/src/editor/paste.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { history, undo } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { htmlToMarkdown } from './html-paste' +import { pasteImages } from './paste' + +let view: EditorView | undefined +afterEach(() => { view?.destroy(); view = undefined; vi.restoreAllMocks() }) + +function paste(html: string, text: string) { + const data = new DataTransfer() + data.setData('text/html', html) + data.setData('text/plain', text) + view!.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) +} + +function editor(importImage: (url: string) => Promise = async () => { throw new Error('此用例不下载图片') }) { + let pending = 0 + const errors: unknown[] = [] + view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({ + upload: async () => { throw new Error('此用例不上传文件') }, + importImage, + update: (change) => { const spec = change(view!.state); if (spec) view!.dispatch(spec) }, + onError: (error) => errors.push(error), + onPending: (delta) => { pending += delta }, + })] }) }) + return { pending: () => pending, errors } +} + +describe('网页粘贴', () => { + it('保留标题、列表、链接、代码块、删除线和表格,并移除活动内容', async () => { + const result = await htmlToMarkdown('

标题

  • 一
  • 二
链接
const x = 1\n
名称值
甲a|b
危险链接', async () => '', () => {}) + expect(result).toContain('## 标题') + expect(result).toMatch(/-\s+一/) + expect(result).toContain('~~二~~') + expect(result).toContain('[链接]()') + expect(result).toContain('```js\nconst x = 1\n```') + expect(result).toContain('| 名称 | 值 |\n| --- | --- |\n| **甲** | a\\|b |') + expect(result).not.toContain('alert(1)') + expect(result).not.toContain('body{}') + expect(result).toContain('危险链接') + }) + + it('相同来源只下载一次,失败保留原图链接并报告原始原因', async () => { + const failure = new Error('来源拒绝下载') + const errors: unknown[] = [] + const urls: string[] = [] + const result = await htmlToMarkdown('图', async (url) => { + urls.push(url) + if (url.endsWith('bad.png')) throw failure + return '笔记.assets/hash.png' + }, (error) => errors.push(error)) + expect(urls.sort()).toEqual(['https://example.com/a.png', 'https://example.com/docs/bad.png']) + expect(result.match(/笔记.assets\/hash.png/g)).toHaveLength(2) + expect(result).toContain('https://example.com/docs/bad.png') + expect(errors).toEqual([failure]) + }) + + it('转换期间的新输入保持原样,撤销一次移除转换后的整次粘贴', async () => { + const state = editor() + paste('

标题

', '标题') + view!.dispatch({ changes: { from: 0, insert: '之后输入' }, userEvent: 'input.type' }) + await vi.waitFor(() => expect(state.pending()).toBe(0)) + expect(view!.state.doc.toString()).toBe('# 标题之后输入') + undo(view!) + expect(view!.state.doc.toString()).toBe('之后输入') + undo(view!) + expect(view!.state.doc.toString()).toBe('') + expect(state.errors).toEqual([]) + }) + + it('用户已修改粘贴区域时不覆盖新输入,Shift 粘贴保持纯文本', async () => { + const state = editor() + view!.dispatch({ changes: { from: 0, insert: '原选区' }, selection: { anchor: 0, head: 3 } }) + paste('

标题

', '标题') + view!.dispatch({ changes: { from: 1, to: 3, insert: '新输入' } }) + await vi.waitFor(() => expect(state.pending()).toBe(0)) + expect(view!.state.doc.toString()).toBe('原新输入') + view!.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', shiftKey: true, ctrlKey: true, bubbles: true })) + paste('

纯文本

', '纯文本') + expect(view!.state.doc.toString()).not.toContain('#') + expect(view!.state.doc.toString()).toContain('纯文本') + expect(state.pending()).toBe(0) + }) + + it('同一位置连续发起的异步粘贴保持发起顺序', async () => { + const state = editor() + paste('第一段', '第一段') + paste('第二段', '第二段') + await vi.waitFor(() => expect(state.pending()).toBe(0)) + expect(view!.state.doc.toString()).toBe('**第一段****第二段**') + }) + + it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => { + const downloads = new Map void>() + const state = editor((url) => new Promise((resolve) => downloads.set(url, resolve))) + paste('第一段', '第一段') + paste('第二段', '第二段') + await vi.waitFor(() => expect(downloads.size).toBe(2)) + downloads.get('https://example.com/2.png')!('n.assets/2.png') + await vi.waitFor(() => expect(view!.state.doc.toString()).toContain('第二段')) + downloads.get('https://example.com/1.png')!('n.assets/1.png') + await vi.waitFor(() => expect(state.pending()).toBe(0)) + expect(view!.state.doc.toString().indexOf('第一段')).toBeLessThan(view!.state.doc.toString().indexOf('第二段')) + paste('取消的粘贴', '取消的粘贴') + await vi.waitFor(() => expect(downloads.size).toBe(3)) + undo(view!) + downloads.get('https://example.com/3.png')!('n.assets/3.png') + await vi.waitFor(() => expect(state.pending()).toBe(0)) + expect(view!.state.doc.toString()).not.toContain('取消的粘贴') + }) +}) diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts index 6f22ec3..9716457 100644 --- a/web/src/editor/paste.ts +++ b/web/src/editor/paste.ts @@ -1,11 +1,13 @@ -import { Facet, type Extension } from '@codemirror/state' +import { isolateHistory } from '@codemirror/commands' +import { Facet, StateEffect, StateField, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state' import { EditorView } from '@codemirror/view' export interface PasteImagesOptions { // 上传文件,返回写入 Markdown 的链接 upload: (file: File) => Promise - // 按笔记身份修改占位,上传结束时该笔记可能已切到后台 - replace: (text: string, replacement: string) => void + importImage: (url: string) => Promise + // 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。 + update: (change: (state: EditorState) => TransactionSpec | undefined) => void onError: (err: unknown) => void // 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件) onPending: (delta: 1 | -1) => void @@ -13,6 +15,63 @@ export interface PasteImagesOptions { const uploadOptions = Facet.define({ combine: (v) => v[0] }) +const pasteRange = StateEffect.define<{ id: object; from: number; to: number } | { id: object }>() +const pendingPastes = StateField.define>({ + create: () => new Map(), + update(value, transaction) { + const next = new Map() + let completed: object | undefined + for (const effect of transaction.effects) if (effect.is(pasteRange) && !('from' in effect.value)) completed = effect.value.id + let afterCompleted = false + for (const [id, range] of value) { + if (id === completed) { afterCompleted = true; continue } + let edited = transaction.isUserEvent('undo') + transaction.changes.iterChangedRanges((from, to) => { + if (from < range.to && to > range.from || from === to && from > range.from && from < range.to) edited = true + }) + // 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。 + // 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。 + const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1 + if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) }) + } + for (const effect of transaction.effects) if (effect.is(pasteRange)) { + if ('from' in effect.value) next.set(effect.value.id, { from: effect.value.from, to: effect.value.to }) + else next.delete(effect.value.id) + } + return next + }, +}) + +function replaceText(opts: PasteImagesOptions, text: string, replacement: string) { + opts.update((state) => { + const at = state.doc.toString().indexOf(text) + if (at >= 0) return { changes: { from: at, to: at + text.length, insert: replacement } } + }) +} + +function insertHTML(view: EditorView, html: string, text: string, opts: PasteImagesOptions) { + const id = {} + const range = view.state.selection.main + opts.onPending(1) + view.dispatch({ effects: pasteRange.of({ id, from: range.from, to: range.to }) }) + const insert = (markdown: string) => { + opts.update((state) => { + const pending = state.field(pendingPastes).get(id) + if (pending) return { + changes: { from: pending.from, to: pending.to, insert: markdown }, + selection: state.selection.main.from === pending.from && state.selection.main.to === pending.to ? { anchor: pending.from + markdown.length } : undefined, + effects: pasteRange.of({ id }), + userEvent: 'input.paste', + annotations: isolateHistory.of('full'), + } + }) + } + void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => { + opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error })) + return text + }).then(insert).catch(opts.onError).finally(() => opts.onPending(-1)) +} + /** * 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。 * 返回是否有图片被插入;编辑器未配置上传(pasteImages)时不处理 @@ -35,9 +94,9 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number): opts.onPending(1) opts .upload(file) - .then((link) => opts.replace(urls[i], link)) + .then((link) => replaceText(opts, urls[i], link)) .catch((err) => { - opts.replace(`![](${urls[i]})`, '') + replaceText(opts, `![](${urls[i]})`, '') opts.onError(err) }) .finally(() => { @@ -50,10 +109,22 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number): // 粘贴或拖入图片 export function pasteImages(opts: PasteImagesOptions): Extension { + let plain = false return [ uploadOptions.of(opts), + pendingPastes, EditorView.domEventHandlers({ + keydown(e) { plain = e.shiftKey }, + keyup(e) { plain = e.shiftKey }, + blur() { plain = false }, paste(e, view) { + if (view.state.readOnly || plain) return false + const html = e.clipboardData?.getData('text/html') + if (html) { + insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts) + e.preventDefault() + return true + } // 个别浏览器/来源只在 items 中提供图片 let files = [...(e.clipboardData?.files ?? [])] if (!files.length) { @@ -64,6 +135,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension { return true }, drop(e, view) { + if (view.state.readOnly) return false const files = [...(e.dataTransfer?.files ?? [])] const pos = view.posAtCoords(e) if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false