Fix attachment scanning for literal text and multiline links

This commit is contained in:
eezd committed 2026-10-21 18:02:18 +08:00
1 parent c08bb8a182
commit b4054189cf
7 files changed
+346 -105

No files matched your search

+83
View File
@@ -85,3 +85,86 @@ func TestReadTokenMayExportButCannotWrite(t *testing.T) {
t.Fatalf("只读令牌获得写权限:%d", code)
}
}
func TestExportDoesNotTreatTextAndFootnotesAsAssets(t *testing.T) {
for _, content := range []string{
"普通文字 ](private.png)\n",
"正文[^1]\n\n[^1]: private.png\n",
"\\[转义文字](private.png)\n",
"[未闭合链接](private.png\n",
"`[代码](private.png)`\n",
"正文[^1]\n\n[^1]: 这是脚注内容。\n",
} {
t.Run(content, func(t *testing.T) {
f := newFixture(t)
f.write("文章.md", content)
if err := os.WriteFile(filepath.Join(f.root, "private.png"), []byte("未选中的私有文件"), 0644); err != nil {
t.Fatal(err)
}
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["文章.md"]}`, nil)
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") || string(body) != content {
t.Fatalf("应只导出原文,不夹带无关文件:%d %s", response.StatusCode, body)
}
})
}
}
func TestExportIncludesAssetsInMultilineLinksAndContainers(t *testing.T) {
for _, content := range []string{
"![图片](\nassets/image.png\n)\n",
"> ![图片](\n> assets/image.png\n> )\n",
"> ![图片][pic]\n>\n> [pic]: assets/image.png\n",
"> > ![图片][pic]\n> >\n> > [pic]: assets/image.png\n",
"- ![图片][pic]\n\n [pic]: assets/image.png\n",
"![图片][pic]\n\n[pic]:\n assets/image.png \"说明\"\n",
"正文[^1]\n\n[^1]: ![图片](assets/image.png)\n",
"![图片][pic]\n\n[pic]: assets/image.png \"[不是链接](private.png)\"\n",
"![图片][pic]\n\n[pic]: assets/image.png\n \"[不是链接](private.png)\"\n",
"![图片][pic]\n\n[pic]: assets/image.png \"说明\n[fake]: private.png\n\"\n",
} {
t.Run(content, func(t *testing.T) {
f := newFixture(t)
f.write("文章.md", content)
if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(f.root, "assets/image.png"), []byte("图片内容"), 0644); err != nil {
t.Fatal(err)
}
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["文章.md"]}`, nil)
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 {
t.Fatalf("导出失败:%d %s", response.StatusCode, data)
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal("附件不应被漏收:", err)
}
files := map[string]string{}
for _, file := range reader.File {
r, err := file.Open()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(r)
r.Close()
if err != nil {
t.Fatal(err)
}
files[file.Name] = string(body)
}
if len(files) != 2 || files["文章.md"] != content || files["assets/image.png"] != "图片内容" {
t.Fatalf("原文或附件不符:%v", files)
}
})
}
}
+5 -20
View File
@@ -61,14 +61,8 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
if e != nil {
return nil, "", e
}
var linkError error
transformMarkdownLines(content, importRef, func(line []byte) []byte {
if linkError != nil {
return line
}
return transformOutsideCode(line, func(text []byte) []byte {
for _, span := range importLinkSpans(text) {
target := string(text[span.start:span.end])
for _, span := range importLinkSpans(content) {
target := string(content[span.start:span.end])
if span.html {
target = html.UnescapeString(target)
} else {
@@ -76,8 +70,7 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
}
u, e := url.Parse(target)
if e != nil {
linkError = fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target)
break
return nil, "", fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target)
}
if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" {
continue
@@ -87,24 +80,16 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
continue
}
if e = v.exportPath(rel); e != nil {
linkError = fmt.Errorf("附件 %s:%w", rel, e)
break
return nil, "", fmt.Errorf("附件 %s:%w", rel, e)
}
info, e := v.Stat(rel)
if e != nil {
linkError = fmt.Errorf("附件 %s:%w", rel, e)
break
return nil, "", fmt.Errorf("附件 %s:%w", rel, e)
}
if !info.IsDir() {
files[rel] = false
}
}
return text
})
})
if linkError != nil {
return nil, "", linkError
}
}
tmp, e := os.CreateTemp("", "rawmark-export-*")
if e != nil {
+4 -9
View File
@@ -11,7 +11,6 @@ import (
"os"
"path"
"path/filepath"
"sort"
"strings"
)
@@ -86,16 +85,14 @@ func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
if err != nil {
return nil, err
}
transform := func(text []byte) []byte {
spans := importLinkSpans(text)
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
spans := importLinkSpans(content)
var out []byte
last := 0
for _, span := range spans {
if span.start < last {
continue
}
target := string(text[span.start:span.end])
target := string(content[span.start:span.end])
if span.html {
target = html.UnescapeString(target)
} else {
@@ -115,11 +112,9 @@ func rewriteImageLinks(content []byte, note, from, to string) ([]byte, error) {
if span.html {
replacement = html.EscapeString(replacement)
}
out = append(out, text[last:span.start]...)
out = append(out, content[last:span.start]...)
out = append(out, replacement...)
last = span.end
}
return append(out, text[last:]...)
}
return transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) }), nil
return append(out, content[last:]...), nil
}
+23
View File
@@ -53,6 +53,29 @@ func TestImageMoveUpdatesLocalReferences(t *testing.T) {
f.Close()
}
func TestImageMovePreservesMultilineMarkdownAndIgnoresLiteralPaths(t *testing.T) {
v := newVault(t)
if err := os.WriteFile(filepath.Join(v.Root(), "old.png"), []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
content := "> ![图片][pic]\r\n>\r\n> [pic]: old.png \"[示例](old.png)\"\r\n\n![跨行](\nold.png\n)\n\n普通文字 ](old.png)\n\n[^1]: old.png\n\n`![代码](old.png)`\n\n> ```md\n> ![代码块](old.png)\n> ```\n"
if _, err := v.Write("文章.md", []byte(content), Precondition{}); err != nil {
t.Fatal(err)
}
if _, err := v.Move("old.png", "new.png", nil); err != nil {
t.Fatal(err)
}
body, _, err := v.Read("文章.md")
if err != nil {
t.Fatal(err)
}
want := strings.Replace(content, "[pic]: old.png", "[pic]: new.png", 1)
want = strings.Replace(want, "![跨行](\nold.png", "![跨行](\nnew.png", 1)
if string(body) != want {
t.Fatalf("应只改写真正的图片引用:\n%s\n期望:\n%s", body, want)
}
}
func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) {
v := newVault(t)
link, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
+181 -69
View File
@@ -15,7 +15,7 @@ import (
"strings"
)
var importRef = regexp.MustCompile(`^ {0,3}\[[^\]\r\n]+\]:[ \t]*`)
var importRef = regexp.MustCompile(`(?m)^ {0,3}\[[^\[\]\r\n]+\]:[ \t]*`)
var importSrc = regexp.MustCompile(`(?i)<(?:img|source|video|audio)\b[^>]*?\bsrc\s*=\s*["']([^"']*)["']`)
var markdownEscape = regexp.MustCompile("\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])")
@@ -24,101 +24,222 @@ type importLink struct {
html bool
}
// linkDestination 返回目的地址本身的区间,不含尖括号、标题或闭合括号。
func linkDestination(text []byte, start int) (int, int) {
for start < len(text) && (text[start] == ' ' || text[start] == '\t') {
start++
// 链接中的空白允许一次换行,但不能跨过段落。
func linkSpace(text []byte, pos int) int {
newline := false
for pos < len(text) {
switch text[pos] {
case ' ', '\t', '\r':
case '\n':
if newline {
return pos
}
newline = true
default:
return pos
}
pos++
}
return pos
}
// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。
func linkDestination(text []byte, pos int) (start, end, next int) {
start = linkSpace(text, pos)
if start == len(text) {
return start, start
return start, start, start
}
if text[start] == '<' {
end := start + 1
for end < len(text) && text[end] != '>' && text[end] != '\n' && text[end] != '\r' {
if text[end] == '\\' && end+1 < len(text) {
for end = start + 1; end < len(text); end++ {
switch text[end] {
case '\\':
if end+1 < len(text) {
end++
}
end++
case '>':
return start + 1, end, end + 1
case '<', '\n', '\r', 0:
return start, start, start
}
if end < len(text) && text[end] == '>' {
return start + 1, end
}
return start, start
return start, start, start
}
depth := 0
end := start
for end < len(text) {
for end = start; end < len(text); end++ {
c := text[end]
if c == '\\' && end+1 < len(text) {
end += 2
end++
continue
}
if c == '(' {
depth++
}
if c == ')' {
} else if c == ')' {
if depth == 0 {
break
}
depth--
}
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
} else if c <= ' ' || c == '<' || c == 0x7f {
break
}
end++
}
if depth != 0 {
return start, start
return start, start, start
}
return start, end
return start, end, end
}
func importLinkSpans(text []byte) []importLink {
var spans []importLink
if m := importRef.FindIndex(text); m != nil {
start, end := linkDestination(text, m[1])
if end > start {
spans = append(spans, importLink{start, end, false})
func linkTitleEnd(text []byte, pos int) int {
quote := text[pos]
close := quote
if quote == '(' {
close = ')'
}
}
for pos := 0; pos < len(text); {
i := bytes.Index(text[pos:], []byte("]("))
if i < 0 {
for i := pos + 1; i < len(text); i++ {
if text[i] == '\\' && i+1 < len(text) {
i++
} else if text[i] == close {
return i + 1
} else if (quote == '(' && text[i] == '(') || (text[i] == '\n' && linkSpace(text, i) < len(text) && text[linkSpace(text, i)] == '\n') {
break
}
i += pos
// 转义的闭括号不是链接语法。
escapes := 0
for j := i - 1; j >= 0 && text[j] == '\\'; j-- {
escapes++
}
start, end := linkDestination(text, i+2)
if escapes%2 == 0 && end > start {
spans = append(spans, importLink{start, end, false})
return -1
}
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。
func importLinkSpans(content []byte) []importLink {
var text []byte
var source []int
lastEnd := 0
transformMarkdownLines(content, importRef, func(line, body []byte, offset int) []byte {
if len(text) > 0 && offset != lastEnd {
text = append(text, '\n', '\n')
source = append(source, -1, -1)
}
start := offset + len(line) - len(body)
text = append(text, body...)
for i := range body {
source = append(source, start+i)
}
lastEnd = offset + len(line)
return line
})
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。
masked := bytes.Repeat([]byte(" "), len(text))
pos := 0
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
transformOutsideCode(paragraph, func(part []byte, offset int) []byte {
copy(masked[pos+offset:], part)
return part
})
pos += len(paragraph)
}
text = masked
var spans []importLink
add := func(start, end int, html bool) {
spans = append(spans, importLink{source[start], source[end-1] + 1, html})
}
definitionEnd := 0
for _, m := range importRef.FindAllIndex(text, -1) {
if m[0] < definitionEnd {
continue
}
label := bytes.TrimSpace(text[m[0]:m[1]])
if bytes.HasPrefix(label, []byte("[^")) || len(bytes.TrimSpace(label[1:bytes.IndexByte(label, ']')])) == 0 {
continue
}
start, end, next := linkDestination(text, m[1])
if end == start {
continue
}
titleStart := linkSpace(text, next)
if titleStart > next && titleStart < len(text) && (text[titleStart] == '"' || text[titleStart] == '\'' || text[titleStart] == '(') {
titleEnd := linkTitleEnd(text, titleStart)
if titleEnd >= 0 {
for titleEnd < len(text) && (text[titleEnd] == ' ' || text[titleEnd] == '\t' || text[titleEnd] == '\r') {
titleEnd++
}
if titleEnd == len(text) || text[titleEnd] == '\n' {
next = titleEnd
}
}
}
for next < len(text) && (text[next] == ' ' || text[next] == '\t' || text[next] == '\r') {
next++
}
if next < len(text) && text[next] != '\n' {
continue
}
add(start, end, false)
definitionEnd = next
// 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。
for i := m[0]; i < next; i++ {
if text[i] != '\n' && text[i] != '\r' {
text[i] = ' '
}
}
}
var brackets []int
for i := 0; i < len(text); i++ {
switch text[i] {
case '\\':
i++
case '\n':
if next := linkSpace(text, i); next < len(text) && text[next] == '\n' {
brackets = nil
}
case '[':
brackets = append(brackets, i)
case ']':
if len(brackets) == 0 {
continue
}
open := brackets[len(brackets)-1]
brackets = brackets[:len(brackets)-1]
if i+1 == len(text) || text[i+1] != '(' {
continue
}
start, end, next := linkDestination(text, i+2)
if end == start {
continue
}
close := linkSpace(text, next)
if close > next && close < len(text) && (text[close] == '"' || text[close] == '\'' || text[close] == '(') {
close = linkTitleEnd(text, close)
if close < 0 {
continue
}
close = linkSpace(text, close)
}
if close < len(text) && text[close] == ')' {
add(start, end, false)
i = close
if open == 0 || text[open-1] != '!' {
brackets = nil // 链接不能嵌套,图片可以位于链接标签内。
}
}
}
pos = max(i+2, end)
}
for _, m := range importSrc.FindAllSubmatchIndex(text, -1) {
spans = append(spans, importLink{m[2], m[3], true})
if m[3] > m[2] {
add(m[2], m[3], true)
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
return spans
}
func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64) ([]byte, []string, error) {
var failure error
missing := map[string]bool{}
replacements := map[string]string{}
transform := func(text []byte) []byte {
spans := importLinkSpans(text)
// 区间可能来自 HTML 或 Markdown,按原始顺序替换,保持其他字节不变。
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
spans := importLinkSpans(content)
var out []byte
last := 0
for _, span := range spans {
if span.start < last || failure != nil {
if span.start < last {
continue
}
raw := string(text[span.start:span.end])
raw := string(content[span.start:span.end])
target := raw
if span.html {
target = html.UnescapeString(target)
@@ -140,8 +261,7 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
continue
}
if err != nil {
failure = err
continue
return nil, nil, err
}
if !info.Mode().IsRegular() {
continue
@@ -152,36 +272,30 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
replacement, found := replacements[rel]
if !found {
if err := ctx.Err(); err != nil {
failure = err
continue
return nil, nil, err
}
data, err := os.ReadFile(filepath.Join(v.root, filepath.FromSlash(rel)))
if err != nil {
failure = err
continue
return nil, nil, err
}
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
dest := filepath.Join(v.root, filepath.FromSlash(AssetsDir(note)), assetName(data, path.Base(rel)))
existing, readErr := os.ReadFile(dest)
if readErr != nil && !os.IsNotExist(readErr) {
failure = readErr
continue
return nil, nil, readErr
}
if readErr == nil && !bytes.Equal(data, existing) {
failure = fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport)
continue
return nil, nil, fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport)
}
if os.IsNotExist(readErr) {
if int64(len(data)) > maxImportExpanded-*total {
failure = ErrTooLarge
continue
return nil, nil, ErrTooLarge
}
*total += int64(len(data))
}
replacement, err = v.SaveAsset(note, path.Base(rel), bytes.NewReader(data), importAssetLimit)
if err != nil {
failure = err
continue
return nil, nil, err
}
replacements[rel] = replacement
}
@@ -194,16 +308,14 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
if span.html {
replacement = html.EscapeString(replacement)
}
out = append(out, text[last:span.start]...)
out = append(out, content[last:span.start]...)
out = append(out, replacement...)
last = span.end
}
return append(out, text[last:]...)
}
updated := transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) })
updated := append(out, content[last:]...)
warnings := []string{}
if len(missing) > 0 {
warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接")
}
return updated, warnings, failure
return updated, warnings, nil
}
+27
View File
@@ -89,6 +89,33 @@ func TestImportZipAssetsAndMove(t *testing.T) {
}
}
func TestImportPreservesContainersAndOnlyRewritesActualLinks(t *testing.T) {
v := newVault(t)
content := "> ![图片][pic]\r\n>\r\n> [pic]: assets/image.png \"[示例](private.png)\"\r\n\n![跨行](\nassets/image.png\n)\n\n普通文字 ](private.png)\n\n[^1]: missing.png\n\n`![代码](assets/image.png)`\n"
source := importZip(t,
zipEntry{name: "文章.md", content: content},
zipEntry{name: "assets/image.png", content: "image"},
zipEntry{name: "private.png", content: "private"},
)
result, err := v.Import(context.Background(), "", "导入", []ImportFile{source})
if err != nil {
t.Fatal(err)
}
if result.Notes != 1 || result.Assets != 3 || len(result.Warnings) != 0 {
t.Fatalf("仅真实图片应生成附件副本,脚注正文不应产生缺失警告:%+v", result)
}
body, _, err := v.Read("导入/文章.md")
if err != nil {
t.Fatal(err)
}
link := EscapeLinkPath("文章.assets") + "/" + assetName([]byte("image"), "x.png")
want := strings.Replace(content, "[pic]: assets/image.png", "[pic]: "+link, 1)
want = strings.Replace(want, "![跨行](\nassets/image.png", "![跨行](\n"+link, 1)
if string(body) != want {
t.Fatalf("应只改写真实链接并保留原文格式:\n%s\n期望:\n%s", body, want)
}
}
func TestImportRejectsInvalidWithoutPublishing(t *testing.T) {
for _, tc := range []struct {
name string
+23 -7
View File
@@ -16,21 +16,25 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*<?)` + target)
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
return transformMarkdownLines(content, refDef, func(line []byte) []byte {
return transformMarkdownLines(content, refDef, func(line, _ []byte, _ int) []byte {
line = refDef.ReplaceAll(line, repl)
return transformOutsideCode(line, func(text []byte) []byte { return inline.ReplaceAll(text, repl) })
return transformOutsideCode(line, func(text []byte, _ int) []byte { return inline.ReplaceAll(text, repl) })
})
}
// transformMarkdownLines 复用链接改写的容器与代码块识别,避免导入时修改代码示例。
func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func([]byte) []byte) []byte {
func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform func(line, body []byte, offset int) []byte) []byte {
lines := bytes.SplitAfter(content, []byte("\n"))
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
var containers []linkContainer
paragraph := false
lineOffset := 0
for i, line := range lines {
offset := lineOffset
lineOffset += len(line)
// 分类时展开制表符,替换仍作用于原始字节,不重新序列化 Markdown。
body := bytes.TrimRight(expandLinkTabs(line), "\r\n")
width := len(body)
matched := 0
for _, container := range containers {
indent := len(body) - len(bytes.TrimLeft(body, " "))
@@ -119,7 +123,17 @@ func transformMarkdownLines(content []byte, refDef *regexp.Regexp, transform fun
continue
}
paragraph = !paragraphBoundary.Match(trimmed) && !refDef.Match(trimmed)
lines[i] = transform(line)
// 将剥离容器时的展开列数映射回原始字节,保留制表符和正文位置。
start, column := 0, 0
for column < width-len(body) {
if line[start] == '\t' {
column += 4 - column%4
} else {
column++
}
start++
}
lines[i] = transform(line, line[start:], offset)
}
return bytes.Join(lines, nil)
}
@@ -169,8 +183,9 @@ func fenceOpen(line []byte) []byte {
}
// transformOutsideCode 只在行内代码 `…` 之外的片段上做转换。
func transformOutsideCode(line []byte, transform func([]byte) []byte) []byte {
func transformOutsideCode(line []byte, transform func(text []byte, offset int) []byte) []byte {
var out []byte
offset := 0
for len(line) > 0 {
start := bytes.IndexByte(line, '`')
if start < 0 {
@@ -185,9 +200,10 @@ func transformOutsideCode(line []byte, transform func([]byte) []byte) []byte {
break
}
codeEnd := start + n + end + n
out = append(out, transform(line[:start])...)
out = append(out, transform(line[:start], offset)...)
out = append(out, line[start:codeEnd]...)
line = line[codeEnd:]
offset += codeEnd
}
return append(out, transform(line)...)
return append(out, transform(line, offset)...)
}