fupstatic: create hashfs
I want to serve static files with hashes in, and I'm too lazy to construct a preprocessing step.
This commit is contained in:
parent
ecbf5a6450
commit
03cf4ea939
2 changed files with 215 additions and 0 deletions
172
web/fup/fupstatic/hashfs.go
Normal file
172
web/fup/fupstatic/hashfs.go
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
package fupstatic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adaptError adapts an error from an underlying filesystem.
|
||||||
|
func adaptError(err error, name string) error {
|
||||||
|
var pErr *fs.PathError
|
||||||
|
if errors.As(err, &pErr) {
|
||||||
|
pErr.Path = name
|
||||||
|
return pErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticFS struct {
|
||||||
|
fs fs.ReadFileFS
|
||||||
|
|
||||||
|
// We need:
|
||||||
|
// original name -> new name
|
||||||
|
// new name -> original name
|
||||||
|
toHashName map[string]string
|
||||||
|
toPlainName map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticFSFileInfo struct {
|
||||||
|
fs.FileInfo
|
||||||
|
newName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi staticFSFileInfo) Name() string { return path.Base(fi.newName) }
|
||||||
|
|
||||||
|
type staticFSDirEntry struct {
|
||||||
|
fs.DirEntry
|
||||||
|
newName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dent staticFSDirEntry) Name() string { return path.Base(dent.newName) }
|
||||||
|
func (dent staticFSDirEntry) Info() (fs.FileInfo, error) {
|
||||||
|
fi, err := dent.DirEntry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return staticFSFileInfo{fi, dent.newName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticFSFile struct {
|
||||||
|
fs.File
|
||||||
|
newName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *staticFSFile) Stat() (fs.FileInfo, error) {
|
||||||
|
fi, err := f.File.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return staticFSFileInfo{fi, f.newName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticFSDirFile struct {
|
||||||
|
fs.ReadDirFile
|
||||||
|
basePath string
|
||||||
|
s *staticFS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f staticFSDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||||
|
dents, err := f.ReadDirFile.ReadDir(n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for n, dent := range dents {
|
||||||
|
fpath := path.Join(f.basePath, dent.Name())
|
||||||
|
if hashName, ok := f.s.toHashName[fpath]; ok {
|
||||||
|
dents[n] = staticFSDirEntry{dent, hashName}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// build computes the hashes needed to serve files.
|
||||||
|
func (s *staticFS) build() error {
|
||||||
|
toHashName := make(map[string]string)
|
||||||
|
toPlainName := make(map[string]string)
|
||||||
|
if err := fs.WalkDir(s.fs, ".", func(fpath string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
// We don't care about directories.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the hash of this file...
|
||||||
|
bs, err := s.fs.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %q: %w", fpath, err)
|
||||||
|
}
|
||||||
|
hexDigest := hex.EncodeToString(sha512.New().Sum(bs)[:6])
|
||||||
|
|
||||||
|
// and fix it up in the filename.
|
||||||
|
oldName := fpath
|
||||||
|
ext := path.Ext(oldName)
|
||||||
|
var newName string
|
||||||
|
if len(ext) == 0 {
|
||||||
|
newName = fmt.Sprintf("%s.%s", oldName, hexDigest)
|
||||||
|
} else {
|
||||||
|
newName = fmt.Sprintf("%s.%s%s", oldName[:len(oldName)-len(ext)], hexDigest, ext)
|
||||||
|
}
|
||||||
|
toHashName[oldName] = newName
|
||||||
|
toPlainName[newName] = oldName
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.toHashName = toHashName
|
||||||
|
s.toPlainName = toPlainName
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupHashedName looks up the filename for the given original name.
|
||||||
|
func (s *staticFS) LookupHashedName(name string) (string, bool) {
|
||||||
|
n, ok := s.toHashName[name]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens the named file.
|
||||||
|
func (s *staticFS) Open(name string) (fs.File, error) {
|
||||||
|
fn, ok := s.toPlainName[name]
|
||||||
|
if !ok {
|
||||||
|
// Try opening it as a plain name.
|
||||||
|
fn = name
|
||||||
|
}
|
||||||
|
f, err := s.fs.Open(fn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, adaptError(err, name)
|
||||||
|
}
|
||||||
|
if rdf, ok := f.(fs.ReadDirFile); ok {
|
||||||
|
return &staticFSDirFile{rdf, name, s}, nil
|
||||||
|
}
|
||||||
|
return &staticFSFile{f, name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile provides an optimised ReadFile implementation.
|
||||||
|
func (s *staticFS) ReadFile(name string) ([]byte, error) {
|
||||||
|
fn, ok := s.toPlainName[name]
|
||||||
|
if !ok {
|
||||||
|
// Try opening it as a plain name.
|
||||||
|
fn = name
|
||||||
|
}
|
||||||
|
f, err := s.fs.ReadFile(fn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, adaptError(err, name)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStaticFS(fs fs.ReadFileFS) *staticFS {
|
||||||
|
s := &staticFS{
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
if err := s.build(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
43
web/fup/fupstatic/hashfs_test.go
Normal file
43
web/fup/fupstatic/hashfs_test.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package fupstatic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashingFS(t *testing.T) {
|
||||||
|
baseFS := fstest.MapFS{
|
||||||
|
"foo": &fstest.MapFile{
|
||||||
|
Data: []byte("hello, world"),
|
||||||
|
},
|
||||||
|
"bar/bar.txt": &fstest.MapFile{
|
||||||
|
Data: []byte("foo bar baz"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
f := newStaticFS(baseFS)
|
||||||
|
|
||||||
|
tcs := []struct {
|
||||||
|
origName string
|
||||||
|
newName string
|
||||||
|
wantContent string
|
||||||
|
}{
|
||||||
|
{"foo", "foo.68656c6c6f2c", "hello, world"},
|
||||||
|
{"bar/bar.txt", "bar/bar.666f6f206261.txt", "foo bar baz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
t.Run(tc.origName, func(t *testing.T) {
|
||||||
|
n, ok := f.LookupHashedName(tc.origName)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("LookupHashedName returned false")
|
||||||
|
} else if n != tc.newName {
|
||||||
|
t.Errorf("LookupHashedName returned %q; want %q", n, tc.newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fstest.TestFS(f, tc.newName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue