diff --git a/fs.go b/fs.go new file mode 100644 index 000000000..e7436c3c5 --- /dev/null +++ b/fs.go @@ -0,0 +1,76 @@ +package buffalo + +import ( + "fmt" + "io/fs" + "os" +) + +// FS wraps a directory and an embed FS that are expected to have the same contents. +// it prioritizes the directory FS and falls back to the embedded FS if the file cannot +// be found on disk. This is useful during development or when deploying with +// assets not embedded in the binary. +// +// Additionally FS hiddes any file named embed.go from the FS. +type FS struct { + embed fs.FS + dir fs.FS +} + +// NewFS returns a new FS that wraps the given directory and embedded FS. +// the embed.FS is expected to embed the same files as the directory FS. +func NewFS(embed fs.ReadDirFS, dir string) FS { + return FS{ + embed: embed, + dir: os.DirFS(dir), + } +} + +// Open implements the FS interface. +func (f FS) Open(name string) (fs.File, error) { + if name == "embed.go" { + return nil, fs.ErrNotExist + } + file, err := f.getFile(name) + if name == "." { + return rootFile{file}, err + } + return file, err +} + +func (f FS) getFile(name string) (fs.File, error) { + file, err := f.dir.Open(name) + if err == nil { + return file, nil + } + + return f.embed.Open(name) +} + +// rootFile wraps the "." directory for hidding the embed.go file. +type rootFile struct { + fs.File +} + +// ReadDir implements the fs.ReadDirFile interface. +func (f rootFile) ReadDir(n int) (entries []fs.DirEntry, err error) { + dir, ok := f.File.(fs.ReadDirFile) + if !ok { + return nil, fmt.Errorf("%T is not a directory", f.File) + } + + entries, err = dir.ReadDir(n) + entries = hideEmbedFile(entries) + return entries, err +} + +func hideEmbedFile(entries []fs.DirEntry) []fs.DirEntry { + result := make([]fs.DirEntry, 0, len(entries)) + + for _, entry := range entries { + if entry.Name() != "embed.go" { + result = append(result, entry) + } + } + return result +} diff --git a/fs_test.go b/fs_test.go new file mode 100644 index 000000000..11d142766 --- /dev/null +++ b/fs_test.go @@ -0,0 +1,100 @@ +package buffalo + +import ( + "io" + "io/fs" + "testing" + + "github.com/gobuffalo/buffalo/internal/testdata/embedded" + "github.com/stretchr/testify/require" +) + +func Test_FS_Disallows_Parent_Folders(t *testing.T) { + r := require.New(t) + + fsys := NewFS(embedded.FS(), "internal/testdata/disk") + r.NotNil(fsys) + + f, err := fsys.Open("../panic.txt") + r.ErrorIs(err, fs.ErrNotExist) + r.Nil(f) + + f, err = fsys.Open("try/../to/../trick/../panic.txt") + r.ErrorIs(err, fs.ErrNotExist) + r.Nil(f) +} + +func Test_FS_Hides_embed_go(t *testing.T) { + r := require.New(t) + + fsys := NewFS(embedded.FS(), "internal/testdata/disk") + r.NotNil(fsys) + + f, err := fsys.Open("embed.go") + r.ErrorIs(err, fs.ErrNotExist) + r.Nil(f) +} + +func Test_FS_Prioritizes_Disk(t *testing.T) { + r := require.New(t) + + fsys := NewFS(embedded.FS(), "internal/testdata/disk") + r.NotNil(fsys) + + f, err := fsys.Open("file.txt") + r.NoError(err) + + b, err := io.ReadAll(f) + r.NoError(err) + + r.Equal("This file is on disk.", string(b)) +} + +func Test_FS_Uses_Embed_If_No_Disk(t *testing.T) { + r := require.New(t) + + fsys := NewFS(embedded.FS(), "internal/testdata/empty") + r.NotNil(fsys) + + f, err := fsys.Open("file.txt") + r.NoError(err) + + b, err := io.ReadAll(f) + r.NoError(err) + + r.Equal("This file is embedded.", string(b)) +} + +func Test_FS_ReadDirFile(t *testing.T) { + r := require.New(t) + + fsys := NewFS(embedded.FS(), "internal/testdata/disk") + r.NotNil(fsys) + + f, err := fsys.Open(".") + r.NoError(err) + + dir, ok := f.(fs.ReadDirFile) + r.True(ok, "folder does not implement fs.ReadDirFile interface") + + // First read should return at most 1 file + entries, err := dir.ReadDir(1) + r.NoError(err) + + // The actual len will be 0 because the first file read is the embed.go file + // this is counter-intuitive, but it's how the fs.ReadDirFile interface is specified; + // if err == nil, just continue to call ReadDir until io.EOF is returned. + r.LessOrEqual(len(entries), 1, "a call to ReadDir must at most return n entries") + + // Second read should return at most 2 files + entries, err = dir.ReadDir(2) + r.NoError(err) + + // The actual len will be 2 (file.txt & file2.txt) + r.LessOrEqual(len(entries), 2, "a call to ReadDir must at most return n entries") + + // trying to read next 2 files (none left) + entries, err = dir.ReadDir(2) + r.ErrorIs(err, io.EOF) + r.Empty(entries) +} diff --git a/internal/testdata/disk/embed.go b/internal/testdata/disk/embed.go new file mode 100644 index 000000000..e69de29bb diff --git a/internal/testdata/disk/file.txt b/internal/testdata/disk/file.txt new file mode 100644 index 000000000..e286859c0 --- /dev/null +++ b/internal/testdata/disk/file.txt @@ -0,0 +1 @@ +This file is on disk. \ No newline at end of file diff --git a/internal/testdata/disk/file2.txt b/internal/testdata/disk/file2.txt new file mode 100644 index 000000000..e69de29bb diff --git a/internal/testdata/embedded/embed.go b/internal/testdata/embedded/embed.go new file mode 100644 index 000000000..c6f4a841f --- /dev/null +++ b/internal/testdata/embedded/embed.go @@ -0,0 +1,12 @@ +package embedded + +import ( + "embed" +) + +//go:embed * +var files embed.FS + +func FS() embed.FS { + return files +} diff --git a/internal/testdata/embedded/file.txt b/internal/testdata/embedded/file.txt new file mode 100644 index 000000000..0ba5d8426 --- /dev/null +++ b/internal/testdata/embedded/file.txt @@ -0,0 +1 @@ +This file is embedded. \ No newline at end of file diff --git a/internal/testdata/panic.txt b/internal/testdata/panic.txt new file mode 100644 index 000000000..764ccb2f2 --- /dev/null +++ b/internal/testdata/panic.txt @@ -0,0 +1 @@ +This file must not be accessible from buffalo.FS. \ No newline at end of file diff --git a/render/html.go b/render/html.go index 48d93255c..0b54af4c1 100644 --- a/render/html.go +++ b/render/html.go @@ -2,6 +2,7 @@ package render import ( "html" + "strings" "github.com/gobuffalo/github_flavored_markdown" "github.com/gobuffalo/plush/v4" @@ -28,6 +29,14 @@ func HTML(names ...string) Renderer { // in the options, then that layout file will be used // automatically. func (e *Engine) HTML(names ...string) Renderer { + // just allow leading slash and remove them here. + // generated actions were various by buffalo versions. + tmp := []string{} + for _, name := range names { + tmp = append(tmp, strings.TrimPrefix(name, "/")) + } + names = tmp + if e.HTMLLayout != "" && len(names) == 1 { names = append(names, e.HTMLLayout) } diff --git a/render/html_test.go b/render/html_test.go index c31ce75bd..f5f269d12 100644 --- a/render/html_test.go +++ b/render/html_test.go @@ -68,3 +68,22 @@ func Test_HTML_WithLayout_Override(t *testing.T) { r.NoError(h.Render(bb, Data{"name": "Mark"})) r.Equal("<html>Mark</html>", strings.TrimSpace(bb.String())) } + +func Test_HTML_LeadingSlash(t *testing.T) { + r := require.New(t) + + rootFS := memfs.New() + r.NoError(rootFS.WriteFile(htmlTemplate, []byte("<%= name %>"), 0644)) + r.NoError(rootFS.WriteFile(htmlLayout, []byte("<body><%= yield %></body>"), 0644)) + + e := NewEngine() + e.TemplatesFS = rootFS + e.HTMLLayout = htmlLayout + + h := e.HTML("/my-template.html") // instead of "my-template.html" + r.Equal("text/html; charset=utf-8", h.ContentType()) + bb := &bytes.Buffer{} + + r.NoError(h.Render(bb, Data{"name": "Mark"})) + r.Equal("<body>Mark</body>", strings.TrimSpace(bb.String())) +} diff --git a/runtime/version.go b/runtime/version.go index 2eca1b0c6..dcec814ed 100644 --- a/runtime/version.go +++ b/runtime/version.go @@ -1,4 +1,4 @@ package runtime // Version is the current version of the buffalo binary -var Version = "v0.18.0" +var Version = "v0.18.1"