// Copyright 2018 Frédéric Guillot. All rights reserved. // Use of this source code is governed by the Apache 2.0 // license that can be found in the LICENSE file. package template // import "miniflux.app/template" import ( "encoding/base64" "fmt" "html/template" "math" "net/mail" "strings" "time" "unicode/utf8" "miniflux.app/config" "miniflux.app/http/route" "miniflux.app/locale" "miniflux.app/model" "miniflux.app/reader/sanitizer" "miniflux.app/timezone" "miniflux.app/url" "github.com/PuerkitoBio/goquery" "github.com/gorilla/mux" "github.com/rylans/getlang" ) type funcMap struct { router *mux.Router } // Map returns a map of template functions that are compiled during template parsing. func (f *funcMap) Map() template.FuncMap { return template.FuncMap{ "formatFileSize": formatFileSize, "dict": dict, "hasKey": hasKey, "truncate": truncate, "isEmail": isEmail, "baseURL": func() string { return config.Opts.BaseURL() }, "rootURL": func() string { return config.Opts.RootURL() }, "hasOAuth2Provider": func(provider string) bool { return config.Opts.OAuth2Provider() == provider }, "route": func(name string, args ...interface{}) string { return route.Path(f.router, name, args...) }, "safeURL": func(url string) template.URL { return template.URL(url) }, "noescape": func(str string) template.HTML { return template.HTML(str) }, "proxyFilter": func(data string) string { return imageProxyFilter(f.router, data) }, "proxyURL": func(link string) string { proxyImages := config.Opts.ProxyImages() if proxyImages == "all" || (proxyImages != "none" && !url.IsHTTPS(link)) { return proxify(f.router, link) } return link }, "domain": func(websiteURL string) string { return url.Domain(websiteURL) }, "hasPrefix": func(str, prefix string) bool { return strings.HasPrefix(str, prefix) }, "contains": func(str, substr string) bool { return strings.Contains(str, substr) }, "isodate": func(ts time.Time) string { return ts.Format("2006-01-02 15:04:05") }, "theme_color": func(theme string) string { return model.ThemeColor(theme) }, // These functions are overrided at runtime after the parsing. "elapsed": func(timezone string, t time.Time) string { return "" }, "t": func(key interface{}, args ...interface{}) string { return "" }, "plural": func(key string, n int, args ...interface{}) string { return "" }, "timeToRead": func(content string) int { return 0 }, } } func dict(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict expects an even number of arguments") } dict := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } dict[key] = values[i+1] } return dict, nil } func hasKey(dict map[string]string, key string) bool { if value, found := dict[key]; found { return value != "" } return false } func truncate(str string, max int) string { runes := 0 for i := range str { runes++ if runes > max { return str[:i] + "…" } } return str } func isEmail(str string) bool { _, err := mail.ParseAddress(str) if err != nil { return false } return true } func elapsedTime(printer *locale.Printer, tz string, t time.Time) string { if t.IsZero() { return printer.Printf("time_elapsed.not_yet") } now := timezone.Now(tz) t = timezone.Convert(tz, t) if now.Before(t) { return printer.Printf("time_elapsed.not_yet") } diff := now.Sub(t) // Duration in seconds s := diff.Seconds() // Duration in days d := int(s / 86400) switch { case s < 60: return printer.Printf("time_elapsed.now") case s < 3600: minutes := int(diff.Minutes()) return printer.Plural("time_elapsed.minutes", minutes, minutes) case s < 86400: hours := int(diff.Hours()) return printer.Plural("time_elapsed.hours", hours, hours) case d == 1: return printer.Printf("time_elapsed.yesterday") case d < 21: return printer.Plural("time_elapsed.days", d, d) case d < 31: weeks := int(math.Round(float64(d) / 7)) return printer.Plural("time_elapsed.weeks", weeks, weeks) case d < 365: months := int(math.Round(float64(d) / 30)) return printer.Plural("time_elapsed.months", months, months) default: years := int(math.Round(float64(d) / 365)) return printer.Plural("time_elapsed.years", years, years) } } func imageProxyFilter(router *mux.Router, data string) string { proxyImages := config.Opts.ProxyImages() if proxyImages == "none" { return data } doc, err := goquery.NewDocumentFromReader(strings.NewReader(data)) if err != nil { return data } doc.Find("img").Each(func(i int, img *goquery.Selection) { if srcAttr, ok := img.Attr("src"); ok { if proxyImages == "all" || !url.IsHTTPS(srcAttr) { img.SetAttr("src", proxify(router, srcAttr)) } } if srcsetAttr, ok := img.Attr("srcset"); ok { if proxyImages == "all" || !url.IsHTTPS(srcsetAttr) { proxifySourceSet(img, router, srcsetAttr) } } }) doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) { if srcsetAttr, ok := sourceElement.Attr("srcset"); ok { if proxyImages == "all" || !url.IsHTTPS(srcsetAttr) { proxifySourceSet(sourceElement, router, srcsetAttr) } } }) output, _ := doc.Find("body").First().Html() return output } func proxifySourceSet(element *goquery.Selection, router *mux.Router, attributeValue string) { var proxifiedSources []string for _, source := range strings.Split(attributeValue, ",") { parts := strings.Split(strings.TrimSpace(source), " ") nbParts := len(parts) if nbParts > 0 { source = proxify(router, parts[0]) if nbParts > 1 { source += " " + parts[1] } proxifiedSources = append(proxifiedSources, source) } } if len(proxifiedSources) > 0 { element.SetAttr("srcset", strings.Join(proxifiedSources, ", ")) } } func proxify(router *mux.Router, link string) string { // We use base64 url encoding to avoid slash in the URL. return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link))) } func formatFileSize(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } func timeToRead(content string) int { sanitizedContent := sanitizer.StripTags(content) languageInfo := getlang.FromString(sanitizedContent) var timeToReadInt int if languageInfo.LanguageCode() == "ko" || languageInfo.LanguageCode() == "zh" || languageInfo.LanguageCode() == "jp" { timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / 500)) } else { nbOfWords := len(strings.Fields(sanitizedContent)) timeToReadInt = int(math.Ceil(float64(nbOfWords) / 265)) } return timeToReadInt }