feat(server): add Go backend with vault, auth, events and REST API

- vault: path validation (no traversal, hidden segments or symlink
  escape), atomic writes with If-Match/If-None-Match revisions, moves
  that carry the note's .assets folder and rewrite its image links,
  trash with restore, content-addressed asset uploads
- auth: single-user Argon2id password, sliding sessions in SQLite,
  login rate limiting, first-run setup gated by a one-time code written
  to the data directory
- watch: fsnotify plus periodic rescans diffed against a snapshot, so
  both RawMark and external edits are broadcast as tree/changed/moved/
  deleted events over WebSocket
- api: stdlib router, SameSite=Strict cookies plus Origin checks,
  sandboxed raw file serving, embedded SPA with gzip and CSP
This commit is contained in:
eezd committed 2026-10-16 18:23:23 +08:00
1 parent 5746f2e9ae
commit c4d2bd4636
15 files changed
+2196

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
package vault
import (
"bytes"
"regexp"
"strings"
)
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过围栏代码块和行内代码。
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
target := `(\./)?(?:` + oldRaw + `|` + oldEsc + `)`
inline := regexp.MustCompile(`(\]\(\s*<?|\bsrc\s*=\s*["'])` + target)
refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*<?)` + target)
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
lines := bytes.SplitAfter(content, []byte("\n"))
var fence []byte // 当前所在围栏代码块的围栏字符串,nil 表示不在代码块中
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " ")
if fence != nil {
if bytes.HasPrefix(trimmed, fence) && len(bytes.TrimSpace(bytes.TrimLeft(trimmed, string(fence[:1])))) == 0 {
fence = nil
}
continue
}
if f := fenceOpen(line); f != nil {
fence = f
continue
}
line = refDef.ReplaceAll(line, repl)
lines[i] = replaceOutsideCode(line, inline, repl)
}
return bytes.Join(lines, nil)
}
// fenceOpen 识别围栏代码块起始行(至多 3 个空格缩进,3 个以上 ` 或 ~),返回围栏字符串
func fenceOpen(line []byte) []byte {
indent := len(line) - len(bytes.TrimLeft(line, " "))
if indent > 3 {
return nil
}
rest := line[indent:]
if len(rest) < 3 || (rest[0] != '`' && rest[0] != '~') {
return nil
}
n := 0
for n < len(rest) && rest[n] == rest[0] {
n++
}
if n < 3 || (rest[0] == '`' && bytes.IndexByte(rest[n:], '`') >= 0) {
return nil
}
return rest[:n]
}
// replaceOutsideCode 只在行内代码 `…` 之外的片段上做替换
func replaceOutsideCode(line []byte, re *regexp.Regexp, repl []byte) []byte {
var out []byte
for len(line) > 0 {
start := bytes.IndexByte(line, '`')
if start < 0 {
break
}
n := 1
for start+n < len(line) && line[start+n] == '`' {
n++
}
end := bytes.Index(line[start+n:], line[start:start+n])
if end < 0 {
break
}
codeEnd := start + n + end + n
out = append(out, re.ReplaceAll(line[:start], repl)...)
out = append(out, line[start:codeEnd]...)
line = line[codeEnd:]
}
return append(out, re.ReplaceAll(line, repl)...)
}