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:
1 parent
5746f2e9ae
commit
c4d2bd4636
15 files changed
+2196
No files matched your search
@@ -0,0 +1,145 @@
|
||||
// RawMark 服务入口。配置全部来自环境变量:
|
||||
//
|
||||
// RAWMARK_DATA 数据目录,默认 /data(存放 rawmark.db 与默认仓库)
|
||||
// RAWMARK_VAULT 笔记仓库目录,默认 $RAWMARK_DATA/vault;可指向已有的 Typora 笔记目录
|
||||
// RAWMARK_ADDR 监听地址,默认 :8080
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/api"
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
"rawmark/internal/watch"
|
||||
"rawmark/web"
|
||||
)
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := env("RAWMARK_ADDR", ":8080")
|
||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||
os.Exit(healthcheck(addr))
|
||||
}
|
||||
if err := run(addr); err != nil {
|
||||
slog.Error("服务退出", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(addr string) error {
|
||||
dataDir := env("RAWMARK_DATA", "/data")
|
||||
vaultDir := env("RAWMARK_VAULT", filepath.Join(dataDir, "vault"))
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := vault.Open(vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开笔记仓库失败: %w", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dataDir, "rawmark.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
am, err := auth.New(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动码写入数据目录下的文件而非日志:只有能访问服务器的人才能完成首次初始化
|
||||
codeFile := filepath.Join(dataDir, "setup-code.txt")
|
||||
if code := am.SetupCode(); code != "" {
|
||||
if err := os.WriteFile(codeFile, []byte(code+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("写入启动码失败: %w", err)
|
||||
}
|
||||
slog.Warn("尚未初始化:请在浏览器中打开 RawMark,输入启动码完成设置", "启动码文件", codeFile)
|
||||
} else {
|
||||
os.Remove(codeFile)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
hub := events.NewHub()
|
||||
if err := watch.Start(ctx, v, hub); err != nil {
|
||||
return fmt.Errorf("启动文件监听失败: %w", err)
|
||||
}
|
||||
go cleanupSessions(ctx, st)
|
||||
|
||||
srv := api.New(v, am, hub)
|
||||
srv.OnSetup = func() { os.Remove(codeFile) }
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: srv.Handler(web.Dist()),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- httpServer.ListenAndServe() }()
|
||||
slog.Info("RawMark 已启动", "addr", addr, "vault", v.Root())
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
slog.Info("正在关闭")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupSessions(ctx context.Context, st *store.Store) {
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := st.DeleteExpiredSessions(); err != nil {
|
||||
slog.Warn("清理过期会话失败", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthcheck 供 Docker HEALTHCHECK 使用(distroless 镜像中没有 curl)
|
||||
func healthcheck(addr string) int {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://127.0.0.1:" + port + "/api/v1/health")
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module rawmark
|
||||
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package api 提供 REST API(/api/v1)、事件推送与前端静态资源。前端只通过这套 API 访问数据。
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "rawmark_session"
|
||||
maxNoteBytes = 20 << 20
|
||||
maxAssetBytes = 50 << 20
|
||||
maxJSONBytes = 1 << 20
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
vault *vault.Vault
|
||||
auth *auth.Manager
|
||||
hub *events.Hub
|
||||
// OnSetup 在首次初始化完成后调用(用于删除启动码文件)
|
||||
OnSetup func()
|
||||
}
|
||||
|
||||
func New(v *vault.Vault, a *auth.Manager, hub *events.Hub) *Server {
|
||||
return &Server{vault: v, auth: a, hub: hub}
|
||||
}
|
||||
|
||||
func (s *Server) Handler(static fs.FS) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]bool{"ok": true}) })
|
||||
mux.HandleFunc("GET /api/v1/auth/state", s.authState)
|
||||
mux.HandleFunc("POST /api/v1/auth/setup", s.setup)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.login)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.logout)
|
||||
|
||||
protected := map[string]http.HandlerFunc{
|
||||
"GET /api/v1/tree": s.tree,
|
||||
"GET /api/v1/files/{path...}": s.readFile,
|
||||
"PUT /api/v1/files/{path...}": s.writeFile,
|
||||
"DELETE /api/v1/files/{path...}": s.deleteFile,
|
||||
"POST /api/v1/move": s.move,
|
||||
"POST /api/v1/folders": s.mkdir,
|
||||
"POST /api/v1/assets": s.uploadAsset,
|
||||
"GET /api/v1/raw/{path...}": s.raw,
|
||||
"GET /api/v1/trash": s.trashList,
|
||||
"POST /api/v1/trash/{id}/restore": s.trashRestore,
|
||||
"DELETE /api/v1/trash/{id}": s.trashPurge,
|
||||
"GET /api/v1/events": s.events,
|
||||
}
|
||||
for pattern, h := range protected {
|
||||
mux.Handle(pattern, s.requireAuth(h))
|
||||
}
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
|
||||
mux.Handle("/", newStaticHandler(static))
|
||||
return securityHeaders(sameOrigin(mux))
|
||||
}
|
||||
|
||||
// ---- 中间件 ----
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// sameOrigin 拒绝跨站的写请求(CSRF 防护的第二道防线,第一道是 SameSite=Strict Cookie)。
|
||||
// 没有 Origin 头的请求来自非浏览器客户端,放行
|
||||
func sameOrigin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
if host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"); host != r.Host {
|
||||
writeError(w, 403, "跨站请求被拒绝")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
ok, err := s.auth.Validate(c.Value)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 401, "登录已过期")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 鉴权 ----
|
||||
|
||||
func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
|
||||
setUp, err := s.auth.IsSetUp()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
authed := false
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if authed, err = s.auth.Validate(c.Value); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"setUp": setUp, "authenticated": authed})
|
||||
}
|
||||
|
||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Code, Password string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
switch err := s.auth.Setup(body.Code, body.Password); {
|
||||
case errors.Is(err, auth.ErrBadSetupCode), errors.Is(err, auth.ErrWeakPassword):
|
||||
writeError(w, 400, err.Error())
|
||||
case errors.Is(err, auth.ErrAlreadySetUp):
|
||||
writeError(w, 409, err.Error())
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
if s.OnSetup != nil {
|
||||
s.OnSetup()
|
||||
}
|
||||
s.startSession(w, r, body.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Password string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
s.startSession(w, r, body.Password)
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
token, err := s.auth.Login(host, password)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, err.Error())
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(auth.SessionTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if err := s.auth.Logout(c.Value); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 文件 ----
|
||||
|
||||
func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.vault.Tree()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
|
||||
data, rev, err := s.vault.Read(r.PathValue("path"))
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("ETag", `"`+rev+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// writeFile 以请求体整体作为笔记内容。If-Match: "rev" 要求版本一致;If-None-Match: * 要求文件不存在;都不带则直接覆盖
|
||||
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
|
||||
if err != nil {
|
||||
writeError(w, 413, "笔记过大")
|
||||
return
|
||||
}
|
||||
pre := vault.Precondition{
|
||||
IfMatch: strings.Trim(r.Header.Get("If-Match"), `"`),
|
||||
IfNoneMatch: r.Header.Get("If-None-Match") == "*",
|
||||
}
|
||||
rev, err := s.vault.Write(r.PathValue("path"), data, pre)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", `"`+rev+`"`)
|
||||
writeJSON(w, 200, map[string]string{"rev": rev})
|
||||
}
|
||||
|
||||
func (s *Server) deleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := s.vault.Delete(r.PathValue("path"))
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
|
||||
func (s *Server) move(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ From, To string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.vault.Move(body.From, body.To); err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Path string }
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.vault.Mkdir(body.Path); err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// uploadAsset 接收 multipart 表单中的 file 字段,存入 ?note= 指定笔记的 .assets 目录
|
||||
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, 400, "缺少上传文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
link, err := s.vault.SaveAsset(r.URL.Query().Get("note"), header.Filename, file, maxAssetBytes)
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"link": link})
|
||||
}
|
||||
|
||||
// raw 输出仓库内任意非隐藏文件(主要是图片)。以沙箱 CSP 返回,防止 SVG/HTML 中的脚本在本站执行
|
||||
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
|
||||
f, info, err := s.vault.OpenRaw(r.PathValue("path"))
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'")
|
||||
if ct := mime.TypeByExtension(path.Ext(info.Name())); ct != "" {
|
||||
h.Set("Content-Type", ct)
|
||||
}
|
||||
h.Set("Cache-Control", "private, no-cache")
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
||||
}
|
||||
|
||||
// ---- 回收站 ----
|
||||
|
||||
func (s *Server) trashList(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.vault.TrashList()
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) trashRestore(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := s.vault.Restore(r.PathValue("id"))
|
||||
if err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
|
||||
func (s *Server) trashPurge(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.vault.Purge(r.PathValue("id")); err != nil {
|
||||
vaultError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- 响应工具 ----
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
|
||||
writeError(w, 400, "请求格式错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("请求处理失败", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||
writeError(w, 500, "服务器内部错误")
|
||||
}
|
||||
|
||||
func vaultError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var conflict *vault.ConflictError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
|
||||
case errors.Is(err, vault.ErrInvalidPath):
|
||||
writeError(w, 400, err.Error())
|
||||
case errors.Is(err, vault.ErrNotFound):
|
||||
writeError(w, 404, err.Error())
|
||||
case errors.Is(err, vault.ErrExists):
|
||||
writeError(w, 409, err.Error())
|
||||
case errors.Is(err, vault.ErrTooLarge):
|
||||
writeError(w, 413, err.Error())
|
||||
default:
|
||||
internalError(w, r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// events 通过 WebSocket 推送仓库变更。websocket.Accept 默认校验 Origin 与 Host 一致
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx
|
||||
ctx := conn.CloseRead(r.Context())
|
||||
msgs, unsubscribe := s.hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
// 定期 ping,避免反向代理因空闲断开连接
|
||||
ping := time.NewTicker(30 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-msgs:
|
||||
if !ok {
|
||||
// 客户端消费过慢被移出广播列表,断开后由客户端重连并全量同步
|
||||
conn.Close(websocket.StatusTryAgainLater, "too slow")
|
||||
return
|
||||
}
|
||||
if err := writeWithTimeout(ctx, conn, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ping.C:
|
||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := conn.Ping(pctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeWithTimeout(ctx context.Context, conn *websocket.Conn, msg []byte) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return conn.Write(ctx, websocket.MessageText, msg)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 应用页面的 CSP:CodeMirror 通过 <style> 注入样式,需要 'unsafe-inline';
|
||||
// 笔记中可引用外部图片,img-src 放开 http/https
|
||||
const appCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob: https: http:; connect-src 'self'; worker-src 'self' blob:; font-src 'self' data:; " +
|
||||
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
||||
|
||||
type staticFile struct {
|
||||
data []byte
|
||||
gzipped []byte // 压缩后不变小时为 nil
|
||||
}
|
||||
|
||||
// staticHandler 提供前端构建产物:带哈希的 assets/ 长期缓存,其余每次校验;
|
||||
// 文本资源首次请求时压缩并缓存在内存(嵌入的文件不会变化)
|
||||
type staticHandler struct {
|
||||
fsys fs.FS
|
||||
started time.Time
|
||||
mu sync.Mutex
|
||||
cache map[string]*staticFile
|
||||
}
|
||||
|
||||
func newStaticHandler(fsys fs.FS) http.Handler {
|
||||
return &staticHandler{fsys: fsys, started: time.Now(), cache: map[string]*staticFile{}}
|
||||
}
|
||||
|
||||
func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeError(w, 405, "不支持的请求方法")
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
f, err := h.load(name)
|
||||
if err != nil {
|
||||
// 前端路由:未知路径一律返回入口页面
|
||||
name = "index.html"
|
||||
if f, err = h.load(name); err != nil {
|
||||
writeError(w, 404, "前端资源缺失")
|
||||
return
|
||||
}
|
||||
}
|
||||
hd := w.Header()
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
hd.Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
hd.Set("Cache-Control", "no-cache")
|
||||
}
|
||||
if strings.HasSuffix(name, ".html") {
|
||||
hd.Set("Content-Security-Policy", appCSP)
|
||||
}
|
||||
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
|
||||
hd.Set("Content-Type", ct)
|
||||
}
|
||||
hd.Add("Vary", "Accept-Encoding")
|
||||
body := f.data
|
||||
if f.gzipped != nil && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
hd.Set("Content-Encoding", "gzip")
|
||||
body = f.gzipped
|
||||
}
|
||||
http.ServeContent(w, r, name, h.started, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func (h *staticHandler) load(name string) (*staticFile, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if f, ok := h.cache[name]; ok {
|
||||
return f, nil
|
||||
}
|
||||
data, err := fs.ReadFile(h.fsys, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &staticFile{data: data}
|
||||
switch path.Ext(name) {
|
||||
case ".js", ".css", ".html", ".svg", ".json", ".txt", ".webmanifest":
|
||||
var buf bytes.Buffer
|
||||
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
zw.Write(data)
|
||||
zw.Close()
|
||||
if buf.Len() < len(data) {
|
||||
f.gzipped = buf.Bytes()
|
||||
}
|
||||
}
|
||||
h.cache[name] = f
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package auth 实现单用户登录:Argon2id 密码哈希、会话令牌与登录限速。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"rawmark/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordKey = "auth.password"
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
MinPasswordLen = 8
|
||||
|
||||
// RFC 9106 推荐的第二组参数(内存受限环境)
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
|
||||
maxFailures = 5
|
||||
failureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlreadySetUp = errors.New("已完成初始化")
|
||||
ErrBadSetupCode = errors.New("启动码错误")
|
||||
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
|
||||
ErrBadCredentials = errors.New("密码错误")
|
||||
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
// 首次初始化需要的一次性启动码,仅打印在服务日志中,防止部署后被他人抢先设置密码
|
||||
setupCode string
|
||||
|
||||
mu sync.Mutex
|
||||
failures map[string][]time.Time
|
||||
}
|
||||
|
||||
func New(s *store.Store) (*Manager, error) {
|
||||
m := &Manager{store: s, failures: map[string][]time.Time{}}
|
||||
ok, err := m.IsSetUp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
m.setupCode = randomCode()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetupCode 返回启动码;已初始化时为空
|
||||
func (m *Manager) SetupCode() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.setupCode
|
||||
}
|
||||
|
||||
func (m *Manager) IsSetUp() (bool, error) {
|
||||
_, ok, err := m.store.Setting(passwordKey)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (m *Manager) Setup(code, password string) error {
|
||||
expected := m.SetupCode()
|
||||
if expected == "" {
|
||||
return ErrAlreadySetUp
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(strings.TrimSpace(code))), []byte(expected)) != 1 {
|
||||
return ErrBadSetupCode
|
||||
}
|
||||
if len([]rune(password)) < MinPasswordLen {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
ok, err := m.store.SetSettingIfAbsent(passwordKey, hashPassword(password))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrAlreadySetUp
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.setupCode = ""
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login 校验密码并创建会话,返回明文令牌(仅存哈希)
|
||||
func (m *Manager) Login(ip, password string) (string, error) {
|
||||
if !m.allow(ip) {
|
||||
return "", ErrRateLimited
|
||||
}
|
||||
encoded, ok, err := m.store.Setting(passwordKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrBadCredentials
|
||||
}
|
||||
valid, err := verifyPassword(encoded, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !valid {
|
||||
m.recordFailure(ip)
|
||||
return "", ErrBadCredentials
|
||||
}
|
||||
m.clearFailures(ip)
|
||||
token := randomToken()
|
||||
if err := m.store.CreateSession(hashToken(token), time.Now().Add(SessionTTL)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Validate(token string) (bool, error) {
|
||||
if token == "" {
|
||||
return false, nil
|
||||
}
|
||||
return m.store.SessionValid(hashToken(token), time.Now().Add(SessionTTL))
|
||||
}
|
||||
|
||||
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
|
||||
|
||||
// ---- 限速:同一来源在时间窗口内失败过多则拒绝 ----
|
||||
|
||||
func (m *Manager) allow(ip string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cutoff := time.Now().Add(-failureWindow)
|
||||
recent := m.failures[ip][:0]
|
||||
for _, t := range m.failures[ip] {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
m.failures[ip] = recent
|
||||
return len(recent) < maxFailures
|
||||
}
|
||||
|
||||
func (m *Manager) recordFailure(ip string) {
|
||||
m.mu.Lock()
|
||||
m.failures[ip] = append(m.failures[ip], time.Now())
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) clearFailures(ip string) {
|
||||
m.mu.Lock()
|
||||
delete(m.failures, ip)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- 密码与令牌 ----
|
||||
|
||||
func hashPassword(password string) string {
|
||||
salt := make([]byte, 16)
|
||||
rand.Read(salt)
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64 := base64.RawStdEncoding
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
b64.EncodeToString(salt), b64.EncodeToString(key))
|
||||
}
|
||||
|
||||
func verifyPassword(encoded, password string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("密码哈希格式无法识别")
|
||||
}
|
||||
var mem uint32
|
||||
var t uint32
|
||||
var p uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &p); err != nil {
|
||||
return false, fmt.Errorf("密码哈希参数无法解析: %w", err)
|
||||
}
|
||||
b64 := base64.RawStdEncoding
|
||||
salt, err := b64.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
want, err := b64.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, t, mem, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomCode 生成形如 ABCD-EFGH 的启动码,去掉易混淆字符
|
||||
func randomCode() string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
out := make([]byte, 0, 9)
|
||||
for i, c := range b {
|
||||
if i == 4 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, alphabet[int(c)%len(alphabet)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package events 把仓库变更广播给所有在线客户端(WebSocket)。
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event 为推送给客户端的消息
|
||||
//
|
||||
// tree 目录结构变化(新增/删除/移动),客户端重新拉取目录树
|
||||
// changed 笔记内容变化,Rev 为新版本
|
||||
// deleted 笔记被删除
|
||||
// moved 笔记被移动(From -> Path),内容版本不变
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Rev string `json:"rev,omitempty"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func NewHub() *Hub { return &Hub{clients: map[chan []byte]struct{}{}} }
|
||||
|
||||
// Subscribe 返回消息通道与取消函数;通道在客户端跟不上时会被关闭,客户端应重连并全量同步
|
||||
func (h *Hub) Subscribe() (<-chan []byte, func()) {
|
||||
ch := make(chan []byte, 64)
|
||||
h.mu.Lock()
|
||||
h.clients[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[ch]; ok {
|
||||
delete(h.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(events ...Event) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
msg, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
slog.Error("序列化事件失败", "err", err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.clients {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
delete(h.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package store 保存应用自身的数据(设置、会话)。笔记内容不在这里,见 vault。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Setting 读取设置项;不存在时 ok 为 false
|
||||
func (s *Store) Setting(key string) (value string, ok bool, err error) {
|
||||
err = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return value, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSettingIfAbsent 仅在设置项不存在时写入,返回是否写入成功(用于首次初始化,防止并发重复设置)
|
||||
func (s *Store) SetSettingIfAbsent(key, value string) (bool, error) {
|
||||
res, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, key, value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
|
||||
_, err := s.db.Exec(`INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)`,
|
||||
tokenHash, time.Now().Unix(), expires.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionValid 校验会话未过期,并顺延有效期(滑动过期)
|
||||
func (s *Store) SessionValid(tokenHash string, extendTo time.Time) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ?`,
|
||||
extendTo.Unix(), tokenHash, time.Now().Unix())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(tokenHash string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAllSessions() error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExpiredSessions() error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// Package vault 管理笔记仓库目录:文件即真源,所有路径以仓库根为基准、使用 "/" 分隔。
|
||||
package vault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPath = errors.New("非法路径")
|
||||
ErrNotFound = errors.New("文件不存在")
|
||||
ErrExists = errors.New("目标已存在")
|
||||
ErrTooLarge = errors.New("文件过大")
|
||||
)
|
||||
|
||||
// ConflictError 表示 If-Match 校验失败,Rev 为服务端当前版本(文件不存在时为空)
|
||||
type ConflictError struct{ Rev string }
|
||||
|
||||
func (e *ConflictError) Error() string { return "版本冲突" }
|
||||
|
||||
const (
|
||||
NoteExt = ".md"
|
||||
AssetsExt = ".assets"
|
||||
metaDir = ".rawmark"
|
||||
tmpPrefix = ".rawmark-tmp-"
|
||||
)
|
||||
|
||||
type Vault struct {
|
||||
root string // 已解析符号链接的绝对路径
|
||||
trash string
|
||||
// 串行化所有写操作,保证"检查版本 + 写入"、移动等复合操作的原子性
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mtime"` // 毫秒
|
||||
}
|
||||
|
||||
type TrashItem struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Dir bool `json:"dir"`
|
||||
DeletedAt int64 `json:"deletedAt"` // 毫秒
|
||||
// 随笔记一起删除的 .assets 目录名(相对回收站条目目录)
|
||||
Assets string `json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
func Open(root string) (*Vault, error) {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
real, err = filepath.Abs(real)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trash := filepath.Join(real, metaDir, "trash")
|
||||
if err := os.MkdirAll(trash, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Vault{root: real, trash: trash}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Root() string { return v.root }
|
||||
|
||||
// Rev 为内容版本号:sha256 前 8 字节的十六进制
|
||||
func Rev(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// CleanPath 校验客户端传入的相对路径:不允许绝对路径、空段、"."/".." 段以及以 "." 开头的隐藏段
|
||||
// (隐藏段包括 .rawmark 元数据目录与 .git 等,一律不经 API 暴露)
|
||||
func CleanPath(p string) (string, error) {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == "" || strings.HasPrefix(seg, ".") {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func IsNote(p string) bool { return strings.HasSuffix(p, NoteExt) && len(p) > len(NoteExt) }
|
||||
|
||||
// assetsDir 返回笔记对应的 Typora 风格图片目录:a.md -> a.assets
|
||||
func assetsDir(note string) string { return strings.TrimSuffix(note, NoteExt) + AssetsExt }
|
||||
|
||||
func (v *Vault) abs(rel string) (string, error) {
|
||||
rel, err := CleanPath(rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(v.root, filepath.FromSlash(rel))
|
||||
if err := v.checkInside(p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// checkInside 确认路径最深的已存在祖先在解析符号链接后仍位于仓库内,防止经符号链接逃逸
|
||||
func (v *Vault) checkInside(p string) error {
|
||||
for cur := p; ; {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err == nil {
|
||||
if real != v.root && !strings.HasPrefix(real, v.root+string(filepath.Separator)) {
|
||||
return ErrInvalidPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return ErrInvalidPath
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Vault) rel(abs string) string {
|
||||
r, _ := filepath.Rel(v.root, abs)
|
||||
return filepath.ToSlash(r)
|
||||
}
|
||||
|
||||
// ---- 读写 ----
|
||||
|
||||
func (v *Vault) Read(rel string) ([]byte, string, error) {
|
||||
if !IsNote(rel) {
|
||||
return nil, "", ErrInvalidPath
|
||||
}
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, Rev(data), nil
|
||||
}
|
||||
|
||||
// Precondition 对应 HTTP 条件请求:IfMatch 要求当前版本一致,IfNoneMatch 要求文件尚不存在;都为空则无条件覆盖
|
||||
type Precondition struct {
|
||||
IfMatch string
|
||||
IfNoneMatch bool
|
||||
}
|
||||
|
||||
func (v *Vault) Write(rel string, content []byte, pre Precondition) (string, error) {
|
||||
if !IsNote(rel) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
cur, err := os.ReadFile(p)
|
||||
exists := err == nil
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if pre.IfNoneMatch && exists {
|
||||
return "", ErrExists
|
||||
}
|
||||
if pre.IfMatch != "" {
|
||||
if !exists {
|
||||
return "", &ConflictError{}
|
||||
}
|
||||
if Rev(cur) != pre.IfMatch {
|
||||
return "", &ConflictError{Rev: Rev(cur)}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := atomicWrite(p, content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Rev(content), nil
|
||||
}
|
||||
|
||||
// atomicWrite 先写同目录临时文件并 fsync,再 rename 覆盖,保证不会留下写了一半的文件
|
||||
func atomicWrite(p string, data []byte) error {
|
||||
dir := filepath.Dir(p)
|
||||
f, err := os.CreateTemp(dir, tmpPrefix+"*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // rename 成功后为空操作
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if d, err := os.Open(dir); err == nil {
|
||||
d.Sync()
|
||||
d.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Vault) Mkdir(rel string) error {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
return ErrExists
|
||||
}
|
||||
return os.MkdirAll(p, 0o755)
|
||||
}
|
||||
|
||||
// OpenRaw 打开仓库内的普通文件(图片等资源),调用方负责关闭
|
||||
func (v *Vault) OpenRaw(rel string) (*os.File, fs.FileInfo, error) {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
f.Close()
|
||||
if err == nil {
|
||||
err = ErrNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, info, nil
|
||||
}
|
||||
|
||||
// ---- 列表 ----
|
||||
|
||||
// skipName 判断目录树中不展示的条目:隐藏文件/目录(含临时文件)与 .assets 图片目录
|
||||
func skipName(name string, dir bool) bool {
|
||||
return strings.HasPrefix(name, ".") || (dir && strings.HasSuffix(name, AssetsExt))
|
||||
}
|
||||
|
||||
// Tree 列出所有文件夹与 .md 笔记
|
||||
func (v *Vault) Tree() ([]Entry, error) {
|
||||
entries := []Entry{}
|
||||
err := filepath.WalkDir(v.root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == v.root {
|
||||
return nil
|
||||
}
|
||||
if skipName(d.Name(), d.IsDir()) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() && !IsNote(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e := Entry{Path: v.rel(p), Dir: d.IsDir(), ModTime: info.ModTime().UnixMilli()}
|
||||
if !e.Dir {
|
||||
e.Size = info.Size()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
return nil
|
||||
})
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// ---- 移动 / 重命名 ----
|
||||
|
||||
// Move 移动或重命名笔记/文件夹。笔记的 .assets 目录随之移动;笔记改名时同步改写正文中指向自身图片目录的链接
|
||||
func (v *Vault) Move(from, to string) error {
|
||||
src, err := v.abs(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := v.abs(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if from == to || strings.HasPrefix(to, from+"/") {
|
||||
return ErrInvalidPath
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
info, err := os.Lstat(src)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
isNote := !info.IsDir()
|
||||
if isNote && (!IsNote(from) || !IsNote(to)) {
|
||||
return ErrInvalidPath
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return ErrExists
|
||||
}
|
||||
srcAssets, dstAssets := "", ""
|
||||
if isNote {
|
||||
if st, err := os.Stat(assetsDir(src)); err == nil && st.IsDir() {
|
||||
srcAssets, dstAssets = assetsDir(src), assetsDir(dst)
|
||||
if _, err := os.Lstat(dstAssets); err == nil {
|
||||
return ErrExists
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if srcAssets != "" {
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return fmt.Errorf("移动图片目录失败: %w", err)
|
||||
}
|
||||
oldBase := strings.TrimSuffix(path.Base(from), NoteExt)
|
||||
newBase := strings.TrimSuffix(path.Base(to), NoteExt)
|
||||
if oldBase != newBase {
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out := RewriteAssetLinks(data, oldBase, newBase); !bytes.Equal(out, data) {
|
||||
return atomicWrite(dst, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 回收站 ----
|
||||
|
||||
func newTrashID() string {
|
||||
var b [4]byte
|
||||
rand.Read(b[:])
|
||||
return time.Now().UTC().Format("20060102T150405.000") + "-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
|
||||
|
||||
// Delete 把笔记(连同 .assets)或文件夹移入回收站
|
||||
func (v *Vault) Delete(rel string) (TrashItem, error) {
|
||||
p, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
info, err := os.Lstat(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return TrashItem{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
if !info.IsDir() && !IsNote(rel) {
|
||||
return TrashItem{}, ErrInvalidPath
|
||||
}
|
||||
item := TrashItem{ID: newTrashID(), Path: rel, Dir: info.IsDir(), DeletedAt: time.Now().UnixMilli()}
|
||||
dir := filepath.Join(v.trash, item.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
if err := os.Rename(p, filepath.Join(dir, filepath.Base(p))); err != nil {
|
||||
os.Remove(dir)
|
||||
return TrashItem{}, err
|
||||
}
|
||||
if !item.Dir {
|
||||
if st, err := os.Stat(assetsDir(p)); err == nil && st.IsDir() {
|
||||
item.Assets = filepath.Base(assetsDir(p))
|
||||
if err := os.Rename(assetsDir(p), filepath.Join(dir, item.Assets)); err != nil {
|
||||
return TrashItem{}, fmt.Errorf("移动图片目录到回收站失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
meta, _ := json.Marshal(item)
|
||||
return item, atomicWrite(filepath.Join(dir, "meta.json"), meta)
|
||||
}
|
||||
|
||||
func (v *Vault) readTrashItem(id string) (TrashItem, error) {
|
||||
if !trashIDPattern.MatchString(id) {
|
||||
return TrashItem{}, ErrInvalidPath
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(v.trash, id, "meta.json"))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return TrashItem{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
var item TrashItem
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
return TrashItem{}, fmt.Errorf("回收站条目 %s 元数据损坏: %w", id, err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (v *Vault) TrashList() ([]TrashItem, error) {
|
||||
dirs, err := os.ReadDir(v.trash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []TrashItem{}
|
||||
for _, d := range dirs {
|
||||
item, err := v.readTrashItem(d.Name())
|
||||
if errors.Is(err, ErrInvalidPath) || errors.Is(err, ErrNotFound) {
|
||||
continue // 非本程序创建的目录或删除未完成的条目
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].DeletedAt > items[j].DeletedAt })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Restore(id string) (TrashItem, error) {
|
||||
item, err := v.readTrashItem(id)
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
dst, err := v.abs(item.Path)
|
||||
if err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return TrashItem{}, ErrExists
|
||||
}
|
||||
if item.Assets != "" {
|
||||
if _, err := os.Lstat(assetsDir(dst)); err == nil {
|
||||
return TrashItem{}, ErrExists
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
dir := filepath.Join(v.trash, id)
|
||||
if err := os.Rename(filepath.Join(dir, filepath.Base(dst)), dst); err != nil {
|
||||
return TrashItem{}, err
|
||||
}
|
||||
if item.Assets != "" {
|
||||
if err := os.Rename(filepath.Join(dir, item.Assets), assetsDir(dst)); err != nil {
|
||||
return TrashItem{}, fmt.Errorf("恢复图片目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
return item, os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
func (v *Vault) Purge(id string) error {
|
||||
if _, err := v.readTrashItem(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(v.trash, id))
|
||||
}
|
||||
|
||||
// ---- 资源 ----
|
||||
|
||||
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
|
||||
|
||||
// SaveAsset 把上传的文件存入笔记的 .assets 目录,以内容哈希命名(相同内容只存一份),
|
||||
// 返回可直接写入 Markdown 的相对链接
|
||||
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
|
||||
if !IsNote(note) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
p, err := v.abs(note)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return "", ErrTooLarge
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(origName))
|
||||
if !extPattern.MatchString(ext) {
|
||||
ext = sniffExt(data)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
name := hex.EncodeToString(sum[:8]) + ext
|
||||
dir := assetsDir(p)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(target); errors.Is(err, fs.ErrNotExist) {
|
||||
if err := atomicWrite(target, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return EscapeLinkPath(path.Base(assetsDir(note))) + "/" + name, nil
|
||||
}
|
||||
|
||||
func sniffExt(data []byte) string {
|
||||
switch http.DetectContentType(data) {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
default:
|
||||
return ".bin"
|
||||
}
|
||||
}
|
||||
|
||||
// EscapeLinkPath 只转义会破坏 Markdown 链接目标的字符,中文等保持原样(与 Typora 写法一致)
|
||||
func EscapeLinkPath(s string) string {
|
||||
return linkEscaper.Replace(s)
|
||||
}
|
||||
|
||||
var linkEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
|
||||
@@ -0,0 +1,154 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newVault(t *testing.T) *Vault {
|
||||
t.Helper()
|
||||
v, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestCleanPathRejectsUnsafe(t *testing.T) {
|
||||
for _, p := range []string{"", "/a.md", "../a.md", "a/../b.md", "a//b.md", "./a.md", ".rawmark/x.md", "a/.git/b.md", "a\\b.md", "a\x00.md"} {
|
||||
if _, err := CleanPath(p); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Errorf("%q 应被拒绝", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"a.md", "目录/笔记 1.md", "a/b/c.md"} {
|
||||
if _, err := CleanPath(p); err != nil {
|
||||
t.Errorf("%q 应被接受: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkEscapeRejected(t *testing.T) {
|
||||
v := newVault(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(v.Root(), "link")); err != nil {
|
||||
t.Skip("无法创建符号链接:", err)
|
||||
}
|
||||
if _, err := v.Write("link/x.md", []byte("x"), Precondition{}); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatalf("经符号链接写到仓库外应被拒绝, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "x.md")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("仓库外出现了文件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreconditions(t *testing.T) {
|
||||
v := newVault(t)
|
||||
rev, err := v.Write("a/b.md", []byte("one"), Precondition{IfNoneMatch: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Write("a/b.md", []byte("x"), Precondition{IfNoneMatch: true}); !errors.Is(err, ErrExists) {
|
||||
t.Fatalf("重复创建应失败, got %v", err)
|
||||
}
|
||||
var conflict *ConflictError
|
||||
if _, err := v.Write("a/b.md", []byte("x"), Precondition{IfMatch: "stale"}); !errors.As(err, &conflict) || conflict.Rev != rev {
|
||||
t.Fatalf("旧版本写入应返回冲突与当前版本, got %v", err)
|
||||
}
|
||||
rev2, err := v.Write("a/b.md", []byte("two"), Precondition{IfMatch: rev})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, got, err := v.Read("a/b.md")
|
||||
if err != nil || string(data) != "two" || got != rev2 {
|
||||
t.Fatalf("读取结果不符: %q %s %v", data, got, err)
|
||||
}
|
||||
if _, err := v.Write("a.txt", []byte("x"), Precondition{}); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("非 .md 文件应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
v := newVault(t)
|
||||
src := "笔记 A.md\n\n\n```\n\n```\n`笔记%20A.assets/inline`\n"
|
||||
if _, err := v.Write("dir/笔记 A.md", []byte(src), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := v.SaveAsset("dir/笔记 A.md", "x.png", strings.NewReader("png"), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(link, "笔记%20A.assets/") {
|
||||
t.Fatalf("资源链接不符: %s", link)
|
||||
}
|
||||
if err := v.Move("dir/笔记 A.md", "other/B.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _, err := v.Read("other/B.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "笔记 A.md\n\n\n```\n\n```\n`笔记%20A.assets/inline`\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("链接改写不符:\n%s", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "other", "B.assets", strings.TrimPrefix(link, "笔记%20A.assets/"))); err != nil {
|
||||
t.Fatal("图片目录未随笔记移动:", err)
|
||||
}
|
||||
if err := v.Move("other", "other/sub"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("文件夹移入自身应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRestore(t *testing.T) {
|
||||
v := newVault(t)
|
||||
v.Write("n.md", []byte("x"), Precondition{})
|
||||
v.SaveAsset("n.md", "a.png", strings.NewReader("img"), 100)
|
||||
item, err := v.Delete("n.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("图片目录应一并移入回收站")
|
||||
}
|
||||
items, _ := v.TrashList()
|
||||
if len(items) != 1 || items[0].ID != item.ID {
|
||||
t.Fatalf("回收站列表不符: %+v", items)
|
||||
}
|
||||
if _, err := v.Restore(item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := v.Read("n.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||
t.Fatal("图片目录未恢复")
|
||||
}
|
||||
if items, _ := v.TrashList(); len(items) != 0 {
|
||||
t.Fatal("恢复后回收站应为空")
|
||||
}
|
||||
if _, err := v.Restore("../../etc"); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Fatal("非法回收站 ID 应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeHidesMetaAndAssets(t *testing.T) {
|
||||
v := newVault(t)
|
||||
v.Write("a.md", []byte("x"), Precondition{})
|
||||
v.SaveAsset("a.md", "i.png", strings.NewReader("i"), 10)
|
||||
v.Mkdir("folder")
|
||||
os.WriteFile(filepath.Join(v.Root(), "note.txt"), []byte("x"), 0o644)
|
||||
entries, err := v.Tree()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paths []string
|
||||
for _, e := range entries {
|
||||
paths = append(paths, e.Path)
|
||||
}
|
||||
if strings.Join(paths, ",") != "a.md,folder" {
|
||||
t.Fatalf("目录树不符: %v", paths)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package watch 监听仓库目录的变化(包括 RawMark 自身的写入和外部编辑器/同步工具的修改),
|
||||
// 与上一次快照比较后把差异作为事件广播。所有变更都经由文件系统这一条路径,客户端据 rev 识别自己的写入。
|
||||
package watch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"rawmark/internal/events"
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
debounce = 100 * time.Millisecond
|
||||
// inotify 在 Docker Desktop 的绑定挂载、NFS 等环境下不可靠,定期全量扫描兜底
|
||||
rescanInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
type fileState struct {
|
||||
dir bool
|
||||
size int64
|
||||
mtime int64
|
||||
rev string
|
||||
}
|
||||
|
||||
type Watcher struct {
|
||||
v *vault.Vault
|
||||
hub *events.Hub
|
||||
fw *fsnotify.Watcher
|
||||
snap map[string]fileState
|
||||
watched map[string]bool
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, v *vault.Vault, hub *events.Hub) error {
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := &Watcher{v: v, hub: hub, fw: fw, snap: map[string]fileState{}, watched: map[string]bool{}}
|
||||
snap, err := w.scan()
|
||||
if err != nil {
|
||||
fw.Close()
|
||||
return err
|
||||
}
|
||||
w.snap = snap
|
||||
go w.loop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Watcher) loop(ctx context.Context) {
|
||||
defer w.fw.Close()
|
||||
timer := time.NewTimer(time.Hour)
|
||||
timer.Stop()
|
||||
ticker := time.NewTicker(rescanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case _, ok := <-w.fw.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
timer.Reset(debounce)
|
||||
case err, ok := <-w.fw.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 事件队列溢出等情况会丢事件,立即全量扫描补齐
|
||||
slog.Warn("文件监听出错,执行全量扫描", "err", err)
|
||||
timer.Reset(debounce)
|
||||
case <-timer.C:
|
||||
w.rescan()
|
||||
case <-ticker.C:
|
||||
w.rescan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) rescan() {
|
||||
next, err := w.scan()
|
||||
if err != nil {
|
||||
slog.Error("扫描仓库失败", "err", err)
|
||||
return
|
||||
}
|
||||
evs := diff(w.snap, next)
|
||||
w.snap = next
|
||||
w.hub.Broadcast(evs...)
|
||||
}
|
||||
|
||||
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
|
||||
func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
entries, err := w.v.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := make(map[string]fileState, len(entries))
|
||||
dirs := map[string]bool{w.v.Root(): true}
|
||||
for _, e := range entries {
|
||||
st := fileState{dir: e.Dir, size: e.Size, mtime: e.ModTime}
|
||||
abs := filepath.Join(w.v.Root(), filepath.FromSlash(e.Path))
|
||||
if e.Dir {
|
||||
dirs[abs] = true
|
||||
} else if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime {
|
||||
st.rev = old.rev
|
||||
} else {
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue // 扫描期间被删除,下一轮会处理
|
||||
}
|
||||
st.rev = vault.Rev(data)
|
||||
}
|
||||
next[e.Path] = st
|
||||
}
|
||||
for d := range dirs {
|
||||
if !w.watched[d] {
|
||||
if err := w.fw.Add(d); err != nil {
|
||||
slog.Warn("添加目录监听失败", "dir", d, "err", err)
|
||||
continue
|
||||
}
|
||||
w.watched[d] = true
|
||||
}
|
||||
}
|
||||
for d := range w.watched {
|
||||
if !dirs[d] {
|
||||
delete(w.watched, d) // 目录已删除,inotify 会自动移除监听
|
||||
}
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// diff 比较两次快照:内容相同的"删除 + 新增"视为移动
|
||||
func diff(prev, next map[string]fileState) []events.Event {
|
||||
var evs []events.Event
|
||||
structural := false
|
||||
removed := map[string][]string{} // rev -> 被删除的路径(内容相同的文件可能有多个)
|
||||
for p, old := range prev {
|
||||
if _, ok := next[p]; ok {
|
||||
continue
|
||||
}
|
||||
structural = true
|
||||
if !old.dir {
|
||||
removed[old.rev] = append(removed[old.rev], p)
|
||||
}
|
||||
}
|
||||
for p, cur := range next {
|
||||
old, existed := prev[p]
|
||||
switch {
|
||||
case !existed:
|
||||
structural = true
|
||||
if froms := removed[cur.rev]; len(froms) > 0 && !cur.dir {
|
||||
removed[cur.rev] = froms[1:]
|
||||
evs = append(evs, events.Event{Type: "moved", From: froms[0], Path: p, Rev: cur.rev})
|
||||
}
|
||||
case old.dir != cur.dir:
|
||||
structural = true
|
||||
case !cur.dir && old.rev != cur.rev:
|
||||
evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
|
||||
}
|
||||
}
|
||||
for _, paths := range removed {
|
||||
for _, p := range paths {
|
||||
evs = append(evs, events.Event{Type: "deleted", Path: p})
|
||||
}
|
||||
}
|
||||
if structural {
|
||||
evs = append(evs, events.Event{Type: "tree"})
|
||||
}
|
||||
return evs
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
prev := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/a.md": {rev: "r1"},
|
||||
"b.md": {rev: "r2"},
|
||||
"c.md": {rev: "same"},
|
||||
"c2.md": {rev: "same"},
|
||||
}
|
||||
next := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/x.md": {rev: "r1"}, // 由 d/a.md 移动而来
|
||||
"b.md": {rev: "r3"}, // 内容修改
|
||||
"e.md": {rev: "same"}, // c.md 与 c2.md 内容相同:其一视为移动到此,另一个为删除
|
||||
}
|
||||
var got []string
|
||||
for _, e := range diff(prev, next) {
|
||||
f := []string{e.Type}
|
||||
if e.From != "" {
|
||||
f = append(f, "c*")
|
||||
if !strings.HasPrefix(e.From, "c") {
|
||||
f[1] = e.From
|
||||
}
|
||||
}
|
||||
if e.Path != "" {
|
||||
f = append(f, e.Path)
|
||||
if e.Type == "deleted" && strings.HasPrefix(e.Path, "c") {
|
||||
f[1] = "c*"
|
||||
}
|
||||
}
|
||||
got = append(got, strings.Join(f, " "))
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "changed b.md|deleted c*|moved c* e.md|moved d/a.md d/x.md|tree"
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Fatalf("事件不符:\n got %q\nwant %q", strings.Join(got, "|"), want)
|
||||
}
|
||||
if evs := diff(next, next); len(evs) != 0 {
|
||||
t.Fatalf("快照相同不应产生事件: %v", evs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package web 把前端构建产物(web/dist,由 npm run build 生成)嵌入 Go 二进制。
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
func Dist() fs.FS {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
panic(err) // 路径为编译期常量,不会失败
|
||||
}
|
||||
return sub
|
||||
}
|
||||
Reference in new issue
Block a user