diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go new file mode 100644 index 0000000..bb75b92 --- /dev/null +++ b/cmd/rawmark/main.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f897e70 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a90227 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..51a2e8c --- /dev/null +++ b/internal/api/api.go @@ -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) + } +} diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..680fcd1 --- /dev/null +++ b/internal/api/events.go @@ -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) +} diff --git a/internal/api/static.go b/internal/api/static.go new file mode 100644 index 0000000..177914a --- /dev/null +++ b/internal/api/static.go @@ -0,0 +1,98 @@ +package api + +import ( + "bytes" + "compress/gzip" + "io/fs" + "mime" + "net/http" + "path" + "strings" + "sync" + "time" +) + +// 应用页面的 CSP:CodeMirror 通过