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 }