assets.go (4507B)
1 // GoToSocial 2 // Copyright (C) GoToSocial Authors admin@gotosocial.org 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful, 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package web 19 20 import ( 21 "fmt" 22 "net/http" 23 "path" 24 "strings" 25 26 "github.com/gin-gonic/gin" 27 "github.com/superseriousbusiness/gotosocial/internal/log" 28 ) 29 30 type fileSystem struct { 31 fs http.FileSystem 32 } 33 34 // FileSystem server that only accepts directory listings when an index.html is available 35 // from https://gist.github.com/hauxe/f2ea1901216177ccf9550a1b8bd59178 36 func (fs fileSystem) Open(path string) (http.File, error) { 37 f, err := fs.fs.Open(path) 38 if err != nil { 39 return nil, err 40 } 41 42 s, _ := f.Stat() 43 if s.IsDir() { 44 index := strings.TrimSuffix(path, "/") + "/index.html" 45 if _, err := fs.fs.Open(index); err != nil { 46 return nil, err 47 } 48 } 49 50 return f, nil 51 } 52 53 // getAssetFileInfo tries to fetch the ETag for the given filePath from the module's 54 // assetsETagCache. If it can't be found there, it uses the provided http.FileSystem 55 // to generate a new ETag to go in the cache, which it then returns. 56 func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) { 57 file, err := fs.Open(filePath) 58 if err != nil { 59 return "", fmt.Errorf("error opening %s: %s", filePath, err) 60 } 61 defer file.Close() 62 63 fileInfo, err := file.Stat() 64 if err != nil { 65 return "", fmt.Errorf("error statting %s: %s", filePath, err) 66 } 67 68 fileLastModified := fileInfo.ModTime() 69 70 if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) { 71 // only return our cached etag if the file wasn't 72 // modified since last time, otherwise generate a 73 // new one; eat fresh! 74 return cachedETag.eTag, nil 75 } 76 77 eTag, err := generateEtag(file) 78 if err != nil { 79 return "", fmt.Errorf("error generating etag: %s", err) 80 } 81 82 // put new entry in cache before we return 83 m.eTagCache.Set(filePath, eTagCacheEntry{ 84 eTag: eTag, 85 lastModified: fileLastModified, 86 }) 87 88 return eTag, nil 89 } 90 91 // assetsCacheControlMiddleware implements Cache-Control header setting, and checks 92 // for files inside the given http.FileSystem. 93 // 94 // The middleware checks if the file has been modified using If-None-Match etag, 95 // if present. If the file hasn't been modified, the middleware returns 304. 96 // 97 // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match 98 // and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 99 // 100 // todo: move this middleware out of the 'web' package and into the 'middleware' 101 // package along with the other middlewares 102 func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc { 103 return func(c *gin.Context) { 104 // Acquire context from gin request. 105 ctx := c.Request.Context() 106 107 // set this Cache-Control header to instruct clients to validate the response with us 108 // before each reuse (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) 109 c.Header(cacheControlHeader, cacheControlNoCache) 110 111 ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader) 112 113 // derive the path of the requested asset inside the provided filesystem 114 upath := c.Request.URL.Path 115 if !strings.HasPrefix(upath, "/") { 116 upath = "/" + upath 117 } 118 assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix) 119 120 // either fetch etag from ttlcache or generate it 121 eTag, err := m.getAssetETag(assetFilePath, fs) 122 if err != nil { 123 log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err) 124 return 125 } 126 127 // Regardless of what happens further down, set the etag header 128 // so that the client has the up-to-date version. 129 c.Header(eTagHeader, eTag) 130 131 // If client already has latest version of the asset, 304 + bail. 132 if ifNoneMatch == eTag { 133 c.AbortWithStatus(http.StatusNotModified) 134 return 135 } 136 137 // else let the rest of the request be processed normally 138 } 139 }