package main import ( "flag" "fmt" "github.com/fsnotify/fsnotify" "github.com/labstack/echo" "github.com/labstack/echo/middleware" "github.com/sergi/go-diff/diffmatchpatch" "github.com/zeebo/xxh3" "io/ioutil" "log" "net/http" "os" "path/filepath" "strings" "time" ) type fileInfo struct { Hash uint64 `json:"hash"` Path string `json:"-"` Name string `json:"name"` Size int64 `json:"size"` ModTime time.Time `json:"mod_time"` } func newFileInfo(name string) *fileInfo { finfo := new(fileInfo) finfo.Hash = xxh3.HashString(name) finfo.Path = name finfo.Name, _ = filepath.Rel(docDir, name) return finfo } func (f *fileInfo) update(info *os.FileInfo) { f.Size = (*info).Size() f.ModTime = (*info).ModTime() } type fileCache struct { Path string `json:"-"` Hash uint64 `json:"-"` Editing map[*wsCli]bool `json:"-"` File string `json:"-"` } func newFileCache(info *fileInfo) *fileCache { c := new(fileCache) c.Editing = make(map[*wsCli]bool) if info != nil { data, _ := ioutil.ReadFile(info.Path) c.File = string(data) c.Path = info.Path c.Hash = info.Hash } return c } func (c *fileCache) Name() string { name, _ := filepath.Rel(docDir, c.Path) return name } func (c *fileCache) AddEditor(cli *wsCli) { c.Editing[cli] = true c.SendWriters(cli, map[string]interface{}{"editors": len(c.Editing)}) } func (c *fileCache) SendWriters(cli *wsCli, data interface{}) { msg := wsMessage{ cli: cli, hash: c.Hash, CMD: wsWritersCMD, Seq: 0, Data: data, } hub.broadcast<-&msg } func (c *fileCache) GetUpdate() map[string]interface{} { return map[string]interface{}{ "editors": len(c.Editing), } } func (c *fileCache) RemoveEditor(cli *wsCli) { delete(c.Editing, cli) if len(c.Editing) == 0 { c.Flush() delete(fileCaches, c.Hash) } else { c.SendWriters(nil, map[string]interface{}{"editors": len(c.Editing)}) } } func (c *fileCache) Flush() { var err error if len(strings.Trim(c.File, " \n\r\t")) == 0 { return } fmt.Printf("Flushing file: %s\n", c.Path) var f *os.File err = os.MkdirAll(filepath.Dir(c.Path), 0775) if err != nil { e.Logger.Errorf("Failed to create directory" + err.Error()) return } _ = watcher.Add(filepath.Dir(c.Path)) f, err = os.OpenFile(c.Path, os.O_CREATE|os.O_WRONLY, os.ModePerm) if err != nil { e.Logger.Errorf("Failed to create/open file" + err.Error()) return } _, err = f.Write([]byte(c.File)) if err != nil { _ = f.Close() e.Logger.Errorf("Failed to create/open file" + err.Error()) return } //if _, ok = fileTree[c.Hash]; !ok { // finfo := newFileInfo(c.Path) // fileTree[c.Hash] = finfo // if info, err := f.Stat(); err != nil { // finfo.update(&info) // } //} _ = f.Close() } var ( e *echo.Echo watcher *fsnotify.Watcher fileTree map[uint64]*fileInfo docDir string fileCaches map[uint64]*fileCache dmp = diffmatchpatch.New() ) func init() { fileTree = make(map[uint64]*fileInfo) fileCaches = make(map[uint64]*fileCache) } func updateFileTree() { _ = filepath.Walk(docDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { _ = watcher.Add(path) return nil } if filepath.Ext(path) != ".md" { return nil } var finfo *fileInfo var ok bool hash := xxh3.HashString(path) if finfo, ok = fileTree[hash]; !ok { finfo = newFileInfo(path) fileTree[hash] = finfo } finfo.update(&info) return nil }) } func fsWatcher() { for { select { case event, ok := <-watcher.Events: if !ok { return } if filepath.Ext(event.Name) != ".md" { continue } hash := xxh3.HashString(event.Name) var finfo *fileInfo var name string if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { if finfo, ok = fileTree[hash]; ok { name = finfo.Name delete(fileTree, hash) } goto annouceUpdate } // on create or write if finfo, ok = fileTree[hash]; !ok { finfo = newFileInfo(event.Name) fileTree[hash] = finfo } name = finfo.Name if event.Op&fsnotify.Write == fsnotify.Write { info, err := os.Stat(event.Name) if err != nil { continue } finfo.update(&info) } annouceUpdate: if len(hub.clients) > 0 { msg := wsMessage{ CMD: wsFileUpdateCMD, Data: name, } hub.broadcast<-&msg } case err, ok := <-watcher.Errors: if !ok { return } log.Println("error:", err) } } } func fixName(name string) string { name = strings.ToLower(name) if strings.HasSuffix(name, ".md") { name = name[:len(name)-3] } name = strings.ReplaceAll(name, " ", "_") name = strings.ReplaceAll(name, ".", "_") return name + ".md" } func calcHash(name string) (uint64, string) { path := filepath.Join(docDir, fixName(name)) hash := xxh3.HashString(path) return hash, path } func main() { adderss := flag.String("b", "127.0.0.1:8288", "Bind address") flag.StringVar(&docDir, "d", "documents", "Document directory") distDir := flag.String("w", "dist", "Web dist directory") allowAddr := flag.String("a", "", "Allow other host to connect to websocket") flag.Parse() var err error e = echo.New() //e.Use(middleware.Logger()) e.Use(middleware.Recover()) defer func() { for _, cached := range fileCaches { cached.Flush() } }() watcher, err = fsnotify.NewWatcher() if err != nil { e.Logger.Fatal(err) } defer watcher.Close() go fsWatcher() err = watcher.Add(docDir) if err != nil { e.Logger.Fatal(err) } updateFileTree() e.Use(middleware.Recover()) e.GET("/_doc", func(c echo.Context) error { return c.JSON(http.StatusOK, fileTree) }) e.GET("/_ws", handleWS) docs := e.Group("/_doc/") docs.Use(middleware.GzipWithConfig(middleware.GzipConfig{Level: 5})) docs.GET("*", func(c echo.Context) error { hash, fname := calcHash(c.Param("*")) if cached, ok := fileCaches[hash]; ok { fmt.Println("Serving cached: " + fname) return c.Blob(http.StatusOK, "text/markdown", []byte(cached.File)) } if _, ok := fileTree[hash]; !ok { return c.Blob(http.StatusOK, "text/markdown", nil) } fmt.Println("Serving from file: " + fname) return c.File(fname) }) docs.DELETE("*", func(c echo.Context) error { hash, fname := calcHash(c.Param("*")) if _, ok := fileTree[hash]; ok { err = os.Remove(fname) if err != nil { e.Logger.Errorf("Failed to remove file %s: %v", fname, err) } } return c.JSON(http.StatusOK, true) }) e.Static("/js", filepath.Join(*distDir, "js")) e.Static("/css", filepath.Join(*distDir, "css")) e.Static("/fonts", filepath.Join(*distDir, "fonts")) e.Static("/img", filepath.Join(*distDir, "img")) e.File("/favicon.png", filepath.Join(*distDir, "favicon.png")) e.File("/favicon.ico", filepath.Join(*distDir, "favicon.png")) e.GET("/*", func(c echo.Context) error { return c.File(filepath.Join(*distDir, "index.html")) }) go hub.run() upgrader.CheckOrigin = func(r *http.Request) bool { fmt.Printf("Host: %s\n", r.Host) return r.Host == *adderss || r.Host == *allowAddr } e.Logger.Fatal(e.Start(*adderss)) }