diff --git a/modules/utils/fs.go b/modules/utils/fs.go index b4c0a8ec..0d18d3a5 100644 --- a/modules/utils/fs.go +++ b/modules/utils/fs.go @@ -2,12 +2,37 @@ package utils import ( "fmt" + "io" + "io/fs" "os" "path/filepath" + "time" "github.com/movsb/taoblog/protocols" ) +// walk returns the file list for a directory. +// Directories are omitted. +// Returned paths are related to dir. +// 返回的所有路径都是相对于 dir 而言的。 +func ListFiles(dir string) ([]*protocols.FileSpec, error) { + bfs, err := ListBackupFiles(dir) + if err != nil { + return nil, err + } + fs := make([]*protocols.FileSpec, 0, len(bfs)) + for _, f := range bfs { + fs = append(fs, &protocols.FileSpec{ + Path: f.Path, + Mode: f.Mode, + Size: f.Size, + Time: f.Time, + }) + } + return fs, nil +} + +// Deprecated. 用 ListFiles。 func ListBackupFiles(dir string) ([]*protocols.BackupFileSpec, error) { dir, err := filepath.Abs(dir) if err != nil { @@ -47,3 +72,36 @@ func ListBackupFiles(dir string) ([]*protocols.BackupFileSpec, error) { return files, nil } + +// 安全写文件。 +// TODO 不应该引入专用的 FileSpec 定义。 +// path 中包含的目录必须存在,否则失败。 +// TODO 没移除失败的文件。 +func WriteFile(path string, mode fs.FileMode, modified time.Time, size int64, r io.Reader) error { + tmp, err := os.CreateTemp(filepath.Dir(path), `taoblog-*`) + if err != nil { + return err + } + + if n, err := io.Copy(tmp, r); err != nil || n != size { + return fmt.Errorf(`write error: %d %v`, n, err) + } + + if err := tmp.Chmod(mode); err != nil { + return err + } + + if err := tmp.Close(); err != nil { + return err + } + + if err := os.Chtimes(tmp.Name(), modified, modified); err != nil { + return err + } + + if err := os.Rename(tmp.Name(), path); err != nil { + return err + } + + return nil +} diff --git a/service/filesystem.go b/service/filesystem.go index 56ad81c9..7faa247a 100644 --- a/service/filesystem.go +++ b/service/filesystem.go @@ -3,15 +3,11 @@ package service import ( "bytes" "errors" - "fmt" "io" - fspkg "io/fs" "log" - "os" - "path/filepath" - "time" "github.com/movsb/taoblog/protocols" + "github.com/movsb/taoblog/service/modules/storage" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -23,7 +19,7 @@ func (s *Service) FileSystem(srv protocols.Management_FileSystemServer) error { initialized := false - var fs _FileSystem + var fs storage.FileSystem for { req, err := srv.Recv() @@ -48,7 +44,7 @@ func (s *Service) FileSystem(srv protocols.Management_FileSystemServer) error { } else if initReq != nil { initialized = true if init := initReq.GetPost(); init != nil { - fs, err = s.fileSystemForPost(init.Id) + fs, err = s.FileSystemForPost(init.Id) } if err != nil { return status.Error(codes.Internal, err.Error()) @@ -110,93 +106,7 @@ func (s *Service) FileSystem(srv protocols.Management_FileSystemServer) error { } } -type _FileSystem interface { - ListFiles() ([]*protocols.FileSpec, error) - DeleteFile(path string) error - WriteFile(spec *protocols.FileSpec, r io.Reader) error -} - -type _FileSystemForPost struct { - s *Service - id int64 -} - -func (fs *_FileSystemForPost) ListFiles() ([]*protocols.FileSpec, error) { - filePaths, err := fs.s.Store().List(fs.id) - if err != nil { - return nil, err - } - - files := []*protocols.FileSpec{} - for _, path := range filePaths { - spec, err := func() (*protocols.FileSpec, error) { - fp, err := fs.s.Store().Open(fs.id, path) - if err != nil { - return nil, err - } - defer fp.Close() - stat, err := fp.Stat() - if err != nil { - return nil, err - } - return &protocols.FileSpec{ - Path: path, - Mode: uint32(stat.Mode()), - Size: uint32(stat.Size()), - Time: uint32(stat.ModTime().Unix()), - }, nil - }() - if err != nil { - return nil, err - } - files = append(files, spec) - } - - return files, nil -} - -func (fs *_FileSystemForPost) DeleteFile(path string) error { - return fs.s.Store().Remove(fs.id, path) -} - -func (fs *_FileSystemForPost) WriteFile(spec *protocols.FileSpec, r io.Reader) error { - finalPath, _ := fs.s.Store().PathOf(fs.id, spec.Path) - - tmp, err := os.CreateTemp(filepath.Dir(finalPath), `taoblog-*`) - if err != nil { - return err - } - - if n, err := io.Copy(tmp, r); err != nil || n != int64(spec.Size) { - return fmt.Errorf(`write error: %d %v`, n, err) - } - - if err := tmp.Chmod(fspkg.FileMode(spec.Mode)); err != nil { - return err - } - - if err := tmp.Close(); err != nil { - return err - } - - t := time.Unix(int64(spec.Time), 0) - - if err := os.Chtimes(tmp.Name(), t, t); err != nil { - return err - } - - if err := os.Rename(tmp.Name(), finalPath); err != nil { - return err - } - - return nil -} - -func (s *Service) fileSystemForPost(id int64) (*_FileSystemForPost, error) { - _ = s.MustGetPost(id) - - return &_FileSystemForPost{ - s: s, - id: id, - }, nil +func (s *Service) FileSystemForPost(id int64) (*storage.Local, error) { + // _ = s.MustGetPost(id) + return storage.NewLocal(s.cfg.Data.File.Path, id), nil } diff --git a/service/main.go b/service/main.go index 94de80fb..cef20811 100644 --- a/service/main.go +++ b/service/main.go @@ -17,8 +17,6 @@ import ( commentgeo "github.com/movsb/taoblog/service/modules/comment_geo" "github.com/movsb/taoblog/service/modules/comment_notify" "github.com/movsb/taoblog/service/modules/search" - "github.com/movsb/taoblog/service/modules/storage" - "github.com/movsb/taoblog/service/modules/storage/local" "github.com/movsb/taoblog/theme/modules/canonical" "github.com/movsb/taorm/taorm" "google.golang.org/grpc" @@ -34,7 +32,6 @@ type Service struct { auth *auth.Auth cmtntf *comment_notify.CommentNotifier cmtgeo *commentgeo.CommentGeo - store storage.Store cache *memory_cache.MemoryCache avatarCache *AvatarCache @@ -52,17 +49,11 @@ type Service struct { // NewService ... func NewService(cfg *config.Config, db *sql.DB, auther *auth.Auth) *Service { - localStorage, err := local.NewLocal(cfg.Data.File.Path) - if err != nil { - panic(err) - } - s := &Service{ cfg: cfg, db: db, tdb: taorm.NewDB(db), auth: auther, - store: localStorage, cache: memory_cache.NewMemoryCache(time.Minute * 10), cmtgeo: commentgeo.NewCommentGeo(context.TODO()), @@ -141,11 +132,6 @@ func (s *Service) Config() *config.Config { return s.cfg } -// Store ... -func (s *Service) Store() storage.Store { - return s.store -} - // MustTxCall ... func (s *Service) MustTxCall(callback func(txs *Service) error) { if err := s.TxCall(callback); err != nil { diff --git a/service/modules/renderers/if.go b/service/modules/renderers/if.go index 269fbd20..f8a9a45f 100644 --- a/service/modules/renderers/if.go +++ b/service/modules/renderers/if.go @@ -5,5 +5,5 @@ type Renderer interface { } type PathResolver interface { - Resolve(path string) (string, error) + Resolve(path string) string } diff --git a/service/modules/renderers/markdown.go b/service/modules/renderers/markdown.go index fa7e9999..80831c9b 100644 --- a/service/modules/renderers/markdown.go +++ b/service/modules/renderers/markdown.go @@ -288,13 +288,8 @@ func (me *_Markdown) renderImage(w util.BufWriter, source []byte, node ast.Node, // 看起来是文章内的相对链接? // 如果是的话,需要 resolve 到相对应的目录。 if !url.IsAbs() && !strings.HasPrefix(url.Path, `/`) && me.pathResolver != nil { - pathRelative, err := me.pathResolver.Resolve(url.Path) - if err != nil { - log.Println(`解析图片相对路径失败`) - // fallthrough - } else { - url.Path = pathRelative - } + pathRelative := me.pathResolver.Resolve(url.Path) + url.Path = pathRelative } width, height := size(url) diff --git a/service/modules/storage/if.go b/service/modules/storage/if.go index f5258072..3858a2fb 100644 --- a/service/modules/storage/if.go +++ b/service/modules/storage/if.go @@ -1,8 +1,15 @@ package storage import ( + "fmt" "io" + fspkg "io/fs" "os" + "path/filepath" + "time" + + "github.com/movsb/taoblog/modules/utils" + "github.com/movsb/taoblog/protocols" ) // File ... @@ -14,16 +21,74 @@ type File interface { Stat() (os.FileInfo, error) } -// Store exposes interfaces to manage post files. -type Store interface { - List(id int64) ([]string, error) - // path is cleaned. - Open(id int64, path string) (File, error) - // path is cleaned. - Create(id int64, path string) (File, error) - // path is cleaned. - Remove(id int64, path string) error +// 文件子系统接口。 +// 所谓“子”,就是针对某篇文章。 +// TODO 应该用标准的接口。 +type FileSystem interface { + ListFiles() ([]*protocols.FileSpec, error) + DeleteFile(path string) error + OpenFile(path string) (File, error) + WriteFile(spec *protocols.FileSpec, r io.Reader) error + Resolve(path string) string +} + +// 针对某篇文章的文件系统实现类。 +// 目录结构:配置的文章附件根目录/文章编号/附件路径。 +// TODO 改成全局一个实例统一管理所有文章的文件。 +type Local struct { + root string + id int64 + dir string +} + +var _ FileSystem = (*Local)(nil) + +func NewLocal(root string, id int64) *Local { + return &Local{ + root: root, + id: id, + dir: filepath.Join(root, fmt.Sprint(id)), + } +} + +func (fs *Local) pathOf(path string) string { + if path == "" { + panic("path cannot be empty") + } + return filepath.Join(fs.dir, filepath.Clean(path)) +} + +func (fs *Local) ListFiles() ([]*protocols.FileSpec, error) { + files, err := utils.ListFiles(fs.dir) + if err != nil { + if os.IsNotExist(err) { + err = nil + } + return nil, err + } + return files, nil +} + +func (fs *Local) DeleteFile(path string) error { + path = filepath.Clean(path) + path = fs.pathOf(path) + return os.Remove(path) +} + +func (fs *Local) OpenFile(path string) (File, error) { + path = fs.pathOf(path) + return os.Open(path) +} + +func (fs *Local) WriteFile(spec *protocols.FileSpec, r io.Reader) error { + path := fs.pathOf(spec.Path) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return utils.WriteFile(path, fspkg.FileMode(spec.Mode), time.Unix(int64(spec.Time), 0), int64(spec.Size), r) +} - // tmp - PathOf(id int64, path string) (string, error) +func (fs *Local) Resolve(path string) string { + return fs.pathOf(path) } diff --git a/service/modules/storage/local/local.go b/service/modules/storage/local/local.go deleted file mode 100644 index 8a5041e4..00000000 --- a/service/modules/storage/local/local.go +++ /dev/null @@ -1,84 +0,0 @@ -package local - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/movsb/taoblog/service/modules/storage" -) - -// Local stores files on local file system. -type Local struct { - root string -} - -// NewLocal ... -func NewLocal(root string) (*Local, error) { - if err := os.MkdirAll(root, 0755); err != nil { - return nil, err - } - return &Local{root: root}, nil -} - -var _ storage.Store = &Local{} - -func (l *Local) path(id int64, path string, createDir bool) (string, error) { - dir := filepath.Join(l.root, fmt.Sprint(id)) - if createDir { - err := os.MkdirAll(dir, 0755) - if err != nil { - return "", err - } - } - path = filepath.Join(dir, filepath.Clean(path)) - return path, nil -} - -// List ... -func (l *Local) List(id int64) ([]string, error) { - path, err := l.path(id, `.`, false) - if err != nil { - return nil, err - } - return walk(path) -} - -// Open ... -func (l *Local) Open(id int64, path string) (storage.File, error) { - path, err := l.path(id, path, false) - if err != nil { - return nil, err - } - fp, err := os.Open(path) - if err != nil { - return nil, err - } - return fp, nil -} - -// Create ... -func (l *Local) Create(id int64, path string) (storage.File, error) { - path, err := l.path(id, path, true) - if err != nil { - return nil, err - } - fp, err := os.Create(path) - if err != nil { - return nil, err - } - return fp, nil -} - -// Remove ... -func (l *Local) Remove(id int64, path string) error { - path, err := l.path(id, path, false) - if err != nil { - return err - } - return os.Remove(path) -} - -func (l *Local) PathOf(id int64, path string) (string, error) { - return l.path(id, path, false) -} diff --git a/service/modules/storage/local/local_test.go b/service/modules/storage/local/local_test.go deleted file mode 100644 index 45739aa2..00000000 --- a/service/modules/storage/local/local_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package local - -import ( - "fmt" - "testing" -) - -func TestWalk(t *testing.T) { - fmt.Println(walk("..")) -} diff --git a/service/modules/storage/local/walk.go b/service/modules/storage/local/walk.go deleted file mode 100644 index 9de9cea3..00000000 --- a/service/modules/storage/local/walk.go +++ /dev/null @@ -1,40 +0,0 @@ -package local - -import ( - "os" - "path/filepath" -) - -// walk returns the file list for a directory. -// Directories are omitted. -// Returned paths are related to dir. -func walk(dir string) (files []string, err error) { - dir, err = filepath.Abs(dir) - if err != nil { - return - } - err = filepath.Walk(dir, - func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return nil - } - - rel, err := filepath.Rel(dir, path) - if err != nil { - return err - } - - files = append(files, rel) - - return nil - }, - ) - if err != nil { - return nil, err - } - - return files, nil -} diff --git a/service/post.go b/service/post.go index ca45c0d3..bb0734e1 100644 --- a/service/post.go +++ b/service/post.go @@ -13,6 +13,7 @@ import ( "github.com/movsb/taoblog/protocols" "github.com/movsb/taoblog/service/models" "github.com/movsb/taoblog/service/modules/renderers" + "github.com/movsb/taoblog/service/modules/storage" "github.com/movsb/taorm/taorm" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -120,19 +121,20 @@ func (s *Service) GetPost(ctx context.Context, in *protocols.GetPostRequest) (*p } func (s *Service) PathResolver(id int64) renderers.PathResolver { - return &PathResolver{s: s, id: id} + return &PathResolver{ + fs: storage.NewLocal(s.cfg.Data.File.Path, id), + } } type PathResolver struct { - s *Service - id int64 + fs storage.FileSystem } -func (r *PathResolver) Resolve(path string) (string, error) { +func (r *PathResolver) Resolve(path string) string { if strings.Contains(path, `://`) { - return path, nil + return path } - return r.s.store.PathOf(r.id, path) + return r.fs.Resolve(path) } // TODO 使用磁盘缓存,而不是内存缓存。 diff --git a/theme/main.go b/theme/main.go index 8b2ca43f..50d59a09 100644 --- a/theme/main.go +++ b/theme/main.go @@ -364,8 +364,11 @@ func (t *Theme) QueryByTags(w http.ResponseWriter, req *http.Request, tags []str } func (t *Theme) QueryFile(w http.ResponseWriter, req *http.Request, postID int64, file string) { - file = filepath.Clean(file) - fp, err := t.service.Store().Open(postID, file) + fs, err := t.service.FileSystemForPost(postID) + if err != nil { + panic(err) + } + fp, err := fs.OpenFile(file) if err != nil { if os.IsNotExist(err) { http.NotFound(w, req)