github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/statik/handler.go (about)

     1  package statik
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"html/template"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"os"
    11  	"path"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    16  	"github.com/cozy/cozy-stack/model/vfs"
    17  	"github.com/cozy/cozy-stack/pkg/assets"
    18  	modelAsset "github.com/cozy/cozy-stack/pkg/assets/model"
    19  	"github.com/cozy/cozy-stack/pkg/config/config"
    20  	"github.com/cozy/cozy-stack/pkg/consts"
    21  	"github.com/cozy/cozy-stack/pkg/i18n"
    22  	"github.com/cozy/cozy-stack/pkg/logger"
    23  	"github.com/cozy/cozy-stack/pkg/utils"
    24  	"github.com/cozy/cozy-stack/web/middlewares"
    25  	"github.com/labstack/echo/v4"
    26  )
    27  
    28  var (
    29  	templatesList = []string{
    30  		"authorize.html",
    31  		"authorize_move.html",
    32  		"authorize_sharing.html",
    33  		"compat.html",
    34  		"confirm_auth.html",
    35  		"confirm_flagship.html",
    36  		"error.html",
    37  		"import.html",
    38  		"install_flagship_app.html",
    39  		"instance_blocked.html",
    40  		"login.html",
    41  		"magic_link_twofactor.html",
    42  		"move_confirm.html",
    43  		"move_delegated_auth.html",
    44  		"move_in_progress.html",
    45  		"move_link.html",
    46  		"move_vault.html",
    47  		"need_onboarding.html",
    48  		"new_app_available.html",
    49  		"oidc_login.html",
    50  		"oidc_twofactor.html",
    51  		"passphrase_choose.html",
    52  		"passphrase_reset.html",
    53  		"share_by_link_password.html",
    54  		"sharing_discovery.html",
    55  		"oauth_clients_limit_exceeded.html",
    56  		"twofactor.html",
    57  	}
    58  )
    59  
    60  const (
    61  	assetsPrefix    = "/assets"
    62  	assetsExtPrefix = "/assets/ext"
    63  )
    64  
    65  var (
    66  	ErrInvalidPath = errors.New("invalid file path")
    67  )
    68  
    69  // AssetRenderer is an interface for both a template renderer and an asset HTTP
    70  // handler.
    71  type AssetRenderer interface {
    72  	echo.Renderer
    73  	http.Handler
    74  }
    75  
    76  type dir string
    77  
    78  func (d dir) Open(name string) (http.File, error) {
    79  	if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
    80  		return nil, fmt.Errorf("%w: invalid character", ErrInvalidPath)
    81  	}
    82  	dir := string(d)
    83  	if dir == "" {
    84  		dir = "."
    85  	}
    86  	name, _ = ExtractAssetID(name)
    87  	fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
    88  	f, err := os.Open(fullName)
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  	return f, nil
    93  }
    94  
    95  // NewDirRenderer returns a renderer with assets opened from a specified local
    96  // directory.
    97  func NewDirRenderer(assetsPath string) (AssetRenderer, error) {
    98  	list := make([]string, len(templatesList))
    99  	for i, name := range templatesList {
   100  		list[i] = filepath.Join(assetsPath, "templates", name)
   101  	}
   102  
   103  	t := template.New("stub")
   104  	h := http.StripPrefix(assetsPrefix, http.FileServer(dir(assetsPath)))
   105  	middlewares.FuncsMap = template.FuncMap{
   106  		"t":         fmt.Sprintf,
   107  		"tHTML":     fmt.Sprintf,
   108  		"split":     strings.Split,
   109  		"replace":   strings.Replace,
   110  		"hasSuffix": strings.HasSuffix,
   111  		"asset":     basicAssetPath,
   112  		"ext":       fileExtension,
   113  		"basename":  basename,
   114  		"filetype":  filetype,
   115  	}
   116  
   117  	var err error
   118  	t, err = t.Funcs(middlewares.FuncsMap).ParseFiles(list...)
   119  	if err != nil {
   120  		return nil, fmt.Errorf("Can't load the assets from %q: %s", assetsPath, err)
   121  	}
   122  
   123  	return &renderer{t: t, Handler: h}, nil
   124  }
   125  
   126  // NewRenderer return a renderer with assets loaded form their packed
   127  // representation into the binary.
   128  func NewRenderer() (AssetRenderer, error) {
   129  	t := template.New("stub")
   130  
   131  	middlewares.FuncsMap = template.FuncMap{
   132  		"t":         fmt.Sprintf,
   133  		"tHTML":     fmt.Sprintf,
   134  		"split":     strings.Split,
   135  		"replace":   strings.Replace,
   136  		"hasSuffix": strings.HasSuffix,
   137  		"asset":     AssetPath,
   138  		"ext":       fileExtension,
   139  		"basename":  basename,
   140  		"filetype":  filetype,
   141  	}
   142  
   143  	for _, name := range templatesList {
   144  		tmpl := t.New(name).Funcs(middlewares.FuncsMap)
   145  		f, err := assets.Open("/templates/"+name, config.DefaultInstanceContext)
   146  		if err != nil {
   147  			return nil, fmt.Errorf("Can't load asset %q: %s", name, err)
   148  		}
   149  		b, err := io.ReadAll(f)
   150  		if err != nil {
   151  			return nil, err
   152  		}
   153  		if _, err = tmpl.Parse(string(b)); err != nil {
   154  			return nil, err
   155  		}
   156  	}
   157  
   158  	return &renderer{t: t, Handler: NewHandler()}, nil
   159  }
   160  
   161  type renderer struct {
   162  	http.Handler
   163  	t *template.Template
   164  }
   165  
   166  func (r *renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
   167  	var funcMap template.FuncMap
   168  	i, ok := middlewares.GetInstanceSafe(c)
   169  	if ok {
   170  		funcMap = template.FuncMap{
   171  			"t":     i.Translate,
   172  			"tHTML": i18n.TranslatorHTML(i.Locale, i.ContextName),
   173  		}
   174  	} else {
   175  		lang := GetLanguageFromHeader(c.Request().Header)
   176  		funcMap = template.FuncMap{
   177  			"t":     i18n.Translator(lang, ""),
   178  			"tHTML": i18n.TranslatorHTML(lang, ""),
   179  		}
   180  	}
   181  	var t *template.Template
   182  	var err error
   183  	if m, ok := data.(echo.Map); ok {
   184  		if context, ok := m["ContextName"].(string); ok {
   185  			if i != nil {
   186  				assets.LoadContextualizedLocale(context, i.Locale)
   187  			}
   188  			if f, err := assets.Open("/templates/"+name, context); err == nil {
   189  				b, err := io.ReadAll(f)
   190  				if err != nil {
   191  					return err
   192  				}
   193  				tmpl := template.New(name).Funcs(middlewares.FuncsMap)
   194  				if _, err = tmpl.Parse(string(b)); err != nil {
   195  					return err
   196  				}
   197  				t = tmpl
   198  			}
   199  		}
   200  	}
   201  	if t == nil {
   202  		t, err = r.t.Clone()
   203  		if err != nil {
   204  			return err
   205  		}
   206  	}
   207  
   208  	// Add some CSP for rendered web pages
   209  	if !config.GetConfig().CSPDisabled {
   210  		middlewares.AppendCSPRule(c, "default-src", "'self'")
   211  		middlewares.AppendCSPRule(c, "img-src", "'self' data:")
   212  	}
   213  
   214  	return t.Funcs(funcMap).ExecuteTemplate(w, name, data)
   215  }
   216  
   217  // AssetPath return the fullpath with unique identifier for a given asset file.
   218  func AssetPath(domain, name string, context ...string) string {
   219  	ctx := config.DefaultInstanceContext
   220  	if len(context) > 0 && context[0] != "" {
   221  		ctx = context[0]
   222  	}
   223  	f, ok := assets.Head(name, ctx)
   224  	if !ok {
   225  		logger.WithNamespace("assets").WithFields(logger.Fields{
   226  			"domain":  domain,
   227  			"name":    name,
   228  			"context": ctx,
   229  		}).Errorf("Cannot find asset")
   230  	}
   231  
   232  	if ok {
   233  		name = f.NameWithSum
   234  		if !f.IsCustom {
   235  			context = nil
   236  		}
   237  	}
   238  	return assetPath(domain, name, context...)
   239  }
   240  
   241  // basicAssetPath is used with DirRenderer to skip the sum in URL, and avoid
   242  // caching the assets.
   243  func basicAssetPath(domain, name string, context ...string) string {
   244  	ctx := config.DefaultInstanceContext
   245  	if len(context) > 0 && context[0] != "" {
   246  		ctx = context[0]
   247  	}
   248  	f, ok := assets.Head(name, ctx)
   249  	if ok && !f.IsCustom {
   250  		context = nil
   251  	}
   252  	return assetPath(domain, name, context...)
   253  }
   254  
   255  func assetPath(domain, name string, context ...string) string {
   256  	if len(context) > 0 && context[0] != "" {
   257  		name = path.Join(assetsExtPrefix, url.PathEscape(context[0]), name)
   258  	} else {
   259  		name = path.Join(assetsPrefix, name)
   260  	}
   261  	if domain != "" {
   262  		return "//" + domain + name
   263  	}
   264  	return name
   265  }
   266  
   267  // Handler implements http.handler for a subpart of the available assets on a
   268  // specified prefix.
   269  type Handler struct{}
   270  
   271  // NewHandler returns a new handler
   272  func NewHandler() Handler {
   273  	return Handler{}
   274  }
   275  
   276  // ServeHTTP implements the http.Handler interface.
   277  func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   278  	if r.Method != http.MethodGet && r.Method != http.MethodHead {
   279  		w.WriteHeader(http.StatusMethodNotAllowed)
   280  		return
   281  	}
   282  
   283  	// The URL path should be formed in one on those forms:
   284  	// /assets/:file...
   285  	// /assets/ext/(:context-name)/:file...
   286  
   287  	var id, name, context string
   288  
   289  	if strings.HasPrefix(r.URL.Path, assetsExtPrefix+"/") {
   290  		nameWithContext := strings.TrimPrefix(r.URL.Path, assetsExtPrefix+"/")
   291  		nameWithContextSplit := strings.SplitN(nameWithContext, "/", 2)
   292  		if len(nameWithContextSplit) != 2 {
   293  			http.Error(w, "File not found", http.StatusNotFound)
   294  			return
   295  		}
   296  		context = nameWithContextSplit[0]
   297  		name = nameWithContextSplit[1]
   298  	} else {
   299  		name = strings.TrimPrefix(r.URL.Path, assetsPrefix)
   300  		if inst, err := lifecycle.GetInstance(r.Host); err == nil {
   301  			context = inst.ContextName
   302  		}
   303  	}
   304  
   305  	name, id = ExtractAssetID(name)
   306  	if len(name) > 0 && name[0] != '/' {
   307  		name = "/" + name
   308  	}
   309  
   310  	f, ok := assets.Get(name, context)
   311  	if !ok {
   312  		http.Error(w, "File not found", http.StatusNotFound)
   313  		return
   314  	}
   315  
   316  	checkETag := id == ""
   317  	h.ServeFile(w, r, f, checkETag)
   318  }
   319  
   320  // ServeFile can be used to respond with an asset file to an HTTP request
   321  func (h *Handler) ServeFile(w http.ResponseWriter, r *http.Request, f *modelAsset.Asset, checkETag bool) {
   322  	if checkETag && utils.CheckPreconditions(w, r, f.Etag) {
   323  		return
   324  	}
   325  
   326  	headers := w.Header()
   327  	headers.Set(echo.HeaderContentType, f.Mime)
   328  	headers.Set(echo.HeaderContentLength, f.Size())
   329  	headers.Set(echo.HeaderVary, echo.HeaderOrigin)
   330  	headers.Add(echo.HeaderVary, echo.HeaderAcceptEncoding)
   331  
   332  	acceptsBrotli := strings.Contains(r.Header.Get(echo.HeaderAcceptEncoding), "br")
   333  	if acceptsBrotli {
   334  		headers.Set(echo.HeaderContentEncoding, "br")
   335  		headers.Set(echo.HeaderContentLength, f.BrotliSize())
   336  	} else {
   337  		headers.Set(echo.HeaderContentLength, f.Size())
   338  	}
   339  
   340  	if checkETag {
   341  		headers.Set("Etag", f.Etag)
   342  		headers.Set("Cache-Control", "no-cache, public")
   343  	} else {
   344  		headers.Set("Cache-Control", "max-age=31536000, public, immutable")
   345  	}
   346  
   347  	if r.Method == http.MethodGet {
   348  		if acceptsBrotli {
   349  			_, _ = io.Copy(w, f.BrotliReader())
   350  		} else {
   351  			_, _ = io.Copy(w, f.Reader())
   352  		}
   353  	}
   354  }
   355  
   356  // GetLanguageFromHeader return the language tag given the Accept-Language
   357  // header.
   358  func GetLanguageFromHeader(header http.Header) (lang string) {
   359  	lang = consts.DefaultLocale
   360  	acceptHeader := header.Get("Accept-Language")
   361  	if acceptHeader == "" {
   362  		return
   363  	}
   364  	acceptLanguages := utils.SplitTrimString(acceptHeader, ",")
   365  	for _, tag := range acceptLanguages {
   366  		// tag may contain a ';q=' for a quality factor that we do not take into
   367  		// account.
   368  		if i := strings.Index(tag, ";q="); i >= 0 {
   369  			tag = tag[:i]
   370  		}
   371  		// tag may contain a '-' to introduce a country variante, that we do not
   372  		// take into account.
   373  		if i := strings.IndexByte(tag, '-'); i >= 0 {
   374  			tag = tag[:i]
   375  		}
   376  		if utils.IsInArray(tag, consts.SupportedLocales) {
   377  			lang = tag
   378  			return
   379  		}
   380  	}
   381  	return
   382  }
   383  
   384  // ExtractAssetID checks if a long hexadecimal string is contained in given
   385  // file path and returns the original file name and ID (if any). For instance
   386  // <foo.badbeedbadbeef.min.js> = <foo.min.js, badbeefbadbeef>
   387  func ExtractAssetID(file string) (string, string) {
   388  	var id string
   389  	base := path.Base(file)
   390  	off1 := strings.IndexByte(base, '.') + 1
   391  	if off1 < len(base) {
   392  		off2 := off1 + strings.IndexByte(base[off1:], '.')
   393  		if off2 > off1 {
   394  			if s := base[off1:off2]; isLongHexString(s) || s == "immutable" {
   395  				dir := path.Dir(file)
   396  				id = s
   397  				file = base[:off1-1] + base[off2:]
   398  				if dir != "." {
   399  					file = path.Join(dir, file)
   400  				}
   401  			}
   402  		}
   403  	}
   404  	return file, id
   405  }
   406  
   407  func isLongHexString(s string) bool {
   408  	if len(s) < 10 {
   409  		return false
   410  	}
   411  	for _, c := range s {
   412  		switch {
   413  		case c >= '0' && c <= '9':
   414  		case c >= 'a' && c <= 'f':
   415  		case c >= 'A' && c <= 'F':
   416  		default:
   417  			return false
   418  		}
   419  	}
   420  	return true
   421  }
   422  
   423  func fileExtension(filename string) string {
   424  	return path.Ext(filename)
   425  }
   426  
   427  func basename(filename string) string {
   428  	ext := fileExtension(filename)
   429  	return strings.TrimSuffix(filename, ext)
   430  }
   431  
   432  func filetype(mime string) string {
   433  	if mime == consts.NoteMimeType {
   434  		return "note"
   435  	}
   436  	_, class := vfs.ExtractMimeAndClass(mime)
   437  	if class == "shortcut" || class == "application" {
   438  		return "files"
   439  	}
   440  	return class
   441  }