Skip to content

Commit 376e30a

Browse files
author
Arthur White
committed
Initial commit
0 parents  commit 376e30a

File tree

9 files changed

+482
-0
lines changed

9 files changed

+482
-0
lines changed

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
_cgo_defun.c
2+
_cgo_export.*
3+
_cgo_gotypes.go
4+
_obj
5+
_test
6+
_testmain.go
7+
._*
8+
.AppleDouble
9+
.DS_Store
10+
.LSOverride
11+
[568vq].out
12+
*.[568vq]
13+
*.a
14+
*.cgo1.go
15+
*.cgo2.c
16+
*.exe
17+
*.o
18+
*.prof
19+
*.so
20+
*.test
21+
ehthumbs.db
22+
Thumbs.db

.travis.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
language: go
2+
3+
go:
4+
- tip
5+
6+
env:
7+
- CGO_ENABLED=0
8+
9+
before_install:
10+
- go get github.com/mattn/goveralls
11+
- go get golang.org/x/tools/cmd/cover
12+
13+
script:
14+
- $HOME/gopath/bin/goveralls -service=travis-ci

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2017 WHITE
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# [![gowww](https://avatars.githubusercontent.com/u/18078923?s=20)](https://github.com/gowww) static [![GoDoc](https://godoc.org/github.com/gowww/static?status.svg)](https://godoc.org/github.com/gowww/static) [![Build](https://travis-ci.org/gowww/static.svg?branch=master)](https://travis-ci.org/gowww/static) [![Coverage](https://coveralls.io/repos/github/gowww/static/badge.svg?branch=master)](https://coveralls.io/github/gowww/static?branch=master) [![Go Report](https://goreportcard.com/badge/github.com/gowww/static)](https://goreportcard.com/report/github.com/gowww/static) ![Status Stable](https://img.shields.io/badge/status-stable-brightgreen.svg)
2+
3+
Package [static](https://godoc.org/github.com/gowww/static) provides a handler for static file serving with cache control and automatic fingerprinting.
4+
5+
## Installing
6+
7+
1. Get package:
8+
9+
```Shell
10+
go get -u github.com/gowww/static
11+
```
12+
13+
2. Import it in your code:
14+
15+
```Go
16+
import "github.com/gowww/static"
17+
```
18+
19+
## Usage
20+
21+
Use [Handle](https://godoc.org/github.com/gowww/static#Handle) with the URL path prefix and the source directory to get a [Handler](https://godoc.org/github.com/gowww/static#Handler) that will serve your static files:
22+
23+
```Go
24+
staticHandler := static.Handle("/static/", "static")
25+
26+
http.Handle("/static/", staticHandler)
27+
```
28+
29+
Use [Handler.Hash](https://godoc.org/github.com/gowww/static#Handler.Hash) to append the file fingerprint to a file name (if the file can be opened, obviously):
30+
31+
```Go
32+
staticHandler.Hash("scripts/main.js")
33+
```
34+
35+
## References
36+
37+
- [Strategies for cache-busting CSS — CSS Tricks](https://css-tricks.com/strategies-for-cache-busting-css/)
38+
- [Fingerprinting images to improve page load speed — Imgix](https://docs.imgix.com/best-practices/fingerprinting-images-improve-page-load-speed)
39+
- [Revving filenames: don’t use querystring](http://www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/)

cache.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package static
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
var hashCacheData = &hashCache{store: make(map[string]*cacheFileInfo)}
9+
10+
type hashCache struct {
11+
sync.RWMutex
12+
store map[string]*cacheFileInfo
13+
}
14+
15+
type cacheFileInfo struct {
16+
Hash string
17+
ModTime time.Time
18+
}
19+
20+
func (c *hashCache) Get(path string) *cacheFileInfo {
21+
c.RLock()
22+
defer c.RUnlock()
23+
return c.store[path]
24+
}
25+
26+
func (c *hashCache) Set(path, hash string, mod time.Time) {
27+
c.Lock()
28+
defer c.Unlock()
29+
c.store[path] = &cacheFileInfo{hash, mod}
30+
}

example_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package static_test
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/gowww/static"
8+
)
9+
10+
func Example() {
11+
staticHandler := static.Handle("/static/", "static")
12+
13+
http.Handle("/static/", staticHandler)
14+
15+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
16+
fmt.Fprintf(w, "Cacheable asset: %s", staticHandler.Hash("main.js"))
17+
})
18+
19+
http.ListenAndServe(":8080", nil)
20+
}
21+
22+
func ExampleHandler_Hash() {
23+
staticHandler := static.Handle("/static/", ".")
24+
25+
// File exists: hash will be appended to the file name.
26+
fmt.Println(staticHandler.Hash("LICENSE"))
27+
28+
// File doesn't exist: the file name will contain no hash.
29+
fmt.Println(staticHandler.Hash("unknown"))
30+
31+
// Output:
32+
// /static/LICENSE.edbc4f9728a8e311b55e081b27e3caff
33+
// /static/unknown
34+
}

handler.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Package static provides a handler for static file serving with cache control and automatic fingerprinting.
2+
package static
3+
4+
import (
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
// A Handler serves files and provides helpers.
13+
type Handler interface {
14+
http.Handler
15+
Hash(string) string
16+
}
17+
18+
type handler struct {
19+
next http.Handler
20+
prefix string
21+
dir string
22+
}
23+
24+
// Handle returns a handler for static file serving.
25+
func Handle(prefix, dir string) Handler {
26+
if !strings.HasPrefix(prefix, "/") || !strings.HasSuffix(prefix, "/") {
27+
panic(fmt.Errorf("static: prefix %q must begin and end with %q", prefix, "/"))
28+
}
29+
// TODO: Cache all file hashes from dir recursively in a goroutine.
30+
return &handler{
31+
next: http.FileServer(http.Dir(dir)),
32+
prefix: prefix,
33+
dir: dir,
34+
}
35+
}
36+
37+
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
38+
r.URL.Path = strings.TrimPrefix(r.URL.Path, h.prefix)
39+
prefix, reqHash, ext := hashSplitFilepath(r.URL.Path)
40+
if reqHash == "" { // No hash: serve file as wanted.
41+
h.next.ServeHTTP(w, r)
42+
return
43+
}
44+
realHash, err := fileHash(filepath.Join(h.dir, prefix+ext))
45+
if err != nil { // Cannot open file to get real hash.
46+
msg, code := toHTTPError(err)
47+
http.Error(w, msg, code)
48+
return
49+
}
50+
if reqHash != realHash { // Hash has changed: redirect to new one.
51+
http.Redirect(w, r, h.prefix+prefix+"."+realHash+ext, http.StatusMovedPermanently)
52+
return
53+
}
54+
r.URL.Path = prefix + ext
55+
w.Header().Set("Cache-Control", "public, max-age=31536000")
56+
w.Header().Set("ETag", `"`+realHash+`"`)
57+
h.next.ServeHTTP(w, r)
58+
}
59+
60+
// Hash returns the URL path from a prefix and a file path.
61+
// If the file was successfully opened, the file hash is appended to the file name.
62+
// dir is the directory where the file is to be found.
63+
func (h *handler) Hash(path string) string {
64+
path = strings.TrimPrefix(path, "/") // Avoid double "/" as h.prefix already ends with one.
65+
hash, err := fileHash(filepath.Join(h.dir, path))
66+
if err != nil {
67+
return h.prefix + path
68+
}
69+
extDotIdx := extDotIndex(path)
70+
if extDotIdx == -1 {
71+
return h.prefix + path + "." + hash
72+
}
73+
return h.prefix + path[:extDotIdx] + "." + hash + path[extDotIdx:]
74+
}
75+
76+
// toHTTPError is copied from net/http/fs.go.
77+
func toHTTPError(err error) (msg string, httpStatus int) {
78+
if os.IsNotExist(err) {
79+
return "404 page not found", http.StatusNotFound
80+
}
81+
if os.IsPermission(err) {
82+
return "403 Forbidden", http.StatusForbidden
83+
}
84+
// Default:
85+
return "500 Internal Server Error", http.StatusInternalServerError
86+
}

hash.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
Considered hashed filename schemes:
3+
Example Problem
4+
main-extra.min.js?v=h45h57r1n6 Query string may be ignored by shitty caches.
5+
main-extra.min.js/h45h57r1n6 Cannot detect content type by extension and sort by file name.
6+
main-extra.min.js.h45h57r1n6 Cannot detect content type by extension.
7+
h45h57r1n6-main-extra.min.js Cannot sort by file name.
8+
main-extra-h45h57r1n6.min.js Cannot sort by complete file name.
9+
main-extra.min-h45h57r1n6.js Ugly.
10+
main-extra.min.h45h57r1n6.js -
11+
*/
12+
13+
package static
14+
15+
import (
16+
"os"
17+
18+
"github.com/gowww/crypto"
19+
)
20+
21+
// isHash tells if s represents a hash.
22+
func isHash(s string) bool {
23+
if len(s) != 32 {
24+
return false
25+
}
26+
for i := 0; i < len(s); i++ {
27+
b := s[i]
28+
if b < '0' || b > '9' && b < 'a' || b > 'z' {
29+
return false
30+
}
31+
}
32+
return true
33+
}
34+
35+
// hashSplitFilepath returns the part before the hash, the hash, and the filename extension (with dot).
36+
func hashSplitFilepath(path string) (prefix, hash, ext string) {
37+
prefix = path
38+
if prefix[len(prefix)-1] == '/' { // Path ends with slash: no hash.
39+
return
40+
}
41+
extSep := extDotIndex(prefix)
42+
if extSep == -1 { // No dot in last part: no hash or extension.
43+
return
44+
}
45+
ext = prefix[extSep:] // Put extension (with dot) in its return variable.
46+
prefix = prefix[:extSep] // Strip extension from prefix.
47+
hashSep := extDotIndex(prefix)
48+
if hashSep == -1 { // A single dot in base: see if extension is in fact a hash.
49+
extWithoutDot := ext[1:]
50+
if isHash(extWithoutDot) {
51+
hash, ext = extWithoutDot, ""
52+
}
53+
return
54+
}
55+
preHash := prefix[hashSep+1:] // Last filename part (without dot) could be a hash.
56+
if isHash(preHash) {
57+
hash = preHash // Put hash in its return variable.
58+
prefix = prefix[:hashSep] // Strip hash from prefix.
59+
}
60+
return
61+
}
62+
63+
func fileHash(path string) (string, error) {
64+
cfi := hashCacheData.Get(path)
65+
if cfi != nil { // Check modification time to validate cached hash.
66+
fi, err := os.Stat(path)
67+
if err != nil {
68+
return "", err
69+
}
70+
if !fi.ModTime().After(cfi.ModTime) {
71+
return cfi.Hash, nil
72+
}
73+
}
74+
75+
f, err := os.Open(path)
76+
if err != nil {
77+
return "", err
78+
}
79+
defer f.Close()
80+
hash, err := crypto.HashMD5(f)
81+
if err != nil {
82+
return "", err
83+
}
84+
stat, err := f.Stat()
85+
if err != nil {
86+
return "", err
87+
}
88+
hashCacheData.Set(path, hash, stat.ModTime())
89+
return hash, nil
90+
}
91+
92+
// extDotIndex returns the last dot index in the last path part.
93+
// It none, -1 is returned.
94+
func extDotIndex(path string) int {
95+
for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- {
96+
if path[i] == '.' {
97+
return i
98+
}
99+
}
100+
return -1
101+
}

0 commit comments

Comments
 (0)