github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/bitwarden/icon.go (about)

     1  package bitwarden
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"io"
     7  	"net"
     8  	"net/http"
     9  	"sort"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/pkg/config/config"
    15  	"github.com/cozy/cozy-stack/pkg/safehttp"
    16  	"github.com/labstack/echo/v4"
    17  	"golang.org/x/net/html"
    18  )
    19  
    20  const (
    21  	cacheTTL = 7 * 24 * time.Hour // 1 week
    22  	maxSize  = 100000             // 100kb
    23  )
    24  
    25  var (
    26  	ErrInvalidDomain      = errors.New("Invalid domain")
    27  	ErrUnauthorizedDomain = errors.New("Unauthorized domain")
    28  	ErrUnauthorizedIP     = errors.New("IP address are not authorized")
    29  )
    30  
    31  // Icon is a simple struct with a content-type and the content of an icon.
    32  type Icon struct {
    33  	Mime string `json:"mime"`
    34  	Body []byte `json:"body"`
    35  }
    36  
    37  // GetIcon returns an icon for the given domain.
    38  func GetIcon(domain string) (*Icon, error) {
    39  	if err := validateDomain(domain); err != nil {
    40  		return nil, err
    41  	}
    42  
    43  	cache := config.GetConfig().CacheStorage
    44  	key := "bw-icons:" + domain
    45  	if data, ok := cache.Get(key); ok {
    46  		if len(data) == 0 {
    47  			return nil, errors.New("No icon")
    48  		}
    49  		icon := &Icon{}
    50  		if err := json.Unmarshal(data, icon); err != nil {
    51  			return nil, err
    52  		}
    53  		return icon, nil
    54  	}
    55  
    56  	icon, err := fetchIcon(domain)
    57  	if err != nil {
    58  		cache.Set(key, nil, cacheTTL)
    59  	} else {
    60  		if data, err := json.Marshal(icon); err == nil {
    61  			cache.Set(key, data, cacheTTL)
    62  		}
    63  	}
    64  	return icon, err
    65  }
    66  
    67  func validateDomain(domain string) error {
    68  	if domain == "" || len(domain) > 255 || strings.Contains(domain, "..") {
    69  		return ErrUnauthorizedDomain
    70  	}
    71  
    72  	for _, c := range domain {
    73  		if c == ' ' || !strconv.IsPrint(c) {
    74  			return ErrInvalidDomain
    75  		}
    76  	}
    77  
    78  	if _, _, err := net.ParseCIDR(domain + "/24"); err == nil {
    79  		return ErrUnauthorizedIP
    80  	}
    81  
    82  	return nil
    83  }
    84  
    85  func fetchIcon(domain string) (*Icon, error) {
    86  	if html, err := getPage(domain); err == nil {
    87  		candidates := getCandidateIcons(domain, html)
    88  		html.Close()
    89  		for _, candidate := range candidates {
    90  			if icon, err := downloadIcon(candidate); err == nil {
    91  				return icon, nil
    92  			}
    93  		}
    94  	}
    95  	return downloadFavicon(domain)
    96  }
    97  
    98  func getPage(domain string) (io.ReadCloser, error) {
    99  	req, err := http.NewRequest(http.MethodGet, "https://"+domain, nil)
   100  	if err != nil {
   101  		return nil, err
   102  	}
   103  	res, err := safehttp.DefaultClient.Do(req)
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  	if res.StatusCode != http.StatusOK {
   108  		res.Body.Close()
   109  		return nil, errors.New("Not status OK")
   110  	}
   111  	ct := strings.ToLower(res.Header.Get(echo.HeaderContentType))
   112  	if !strings.Contains(ct, echo.MIMETextHTML) {
   113  		res.Body.Close()
   114  		return nil, errors.New("Not html")
   115  	}
   116  	return res.Body, nil
   117  }
   118  
   119  func getCandidateIcons(domain string, r io.Reader) []string {
   120  	tokenizer := html.NewTokenizer(r)
   121  	candidates := make(map[string]int)
   122  
   123  	// Consider only the first 1000 tokens, as the candidates icons must be in
   124  	// the <head>, and it avoid reading the whole html page.
   125  	for i := 0; i < 1000; i++ {
   126  		done := false
   127  		switch tokenizer.Next() {
   128  		case html.ErrorToken:
   129  			// End of the document, we're done
   130  			done = true
   131  		case html.StartTagToken, html.SelfClosingTagToken:
   132  			t := tokenizer.Token()
   133  			if u, p := getLinkIcon(domain, t); p >= 0 {
   134  				candidates[u] = p
   135  			}
   136  		}
   137  		if done {
   138  			break
   139  		}
   140  	}
   141  
   142  	sorted := make([]string, 0, len(candidates))
   143  	for k := range candidates {
   144  		sorted = append(sorted, k)
   145  	}
   146  	sort.SliceStable(sorted, func(i, j int) bool {
   147  		return candidates[sorted[i]] > candidates[sorted[j]]
   148  	})
   149  	return sorted
   150  }
   151  
   152  // getLinkIcon returns the href and the priority for the link.
   153  // -1 means that it is not a suitable icon link.
   154  // Higher priority is better.
   155  func getLinkIcon(domain string, t html.Token) (string, int) {
   156  	if strings.ToLower(t.Data) != "link" {
   157  		return "", -1
   158  	}
   159  
   160  	isIcon := false
   161  	href := ""
   162  	priority := 100
   163  	for _, attr := range t.Attr {
   164  		switch strings.ToLower(attr.Key) {
   165  		case "rel":
   166  			vals := strings.Split(strings.ToLower(attr.Val), " ")
   167  			for _, val := range vals {
   168  				if val == "icon" || val == "apple-touch-icon" {
   169  					isIcon = true
   170  					if val == "icon" {
   171  						priority += 10
   172  					}
   173  				}
   174  			}
   175  
   176  		case "href":
   177  			href = attr.Val
   178  			if strings.HasSuffix(href, ".png") {
   179  				priority += 2
   180  			}
   181  
   182  		case "sizes":
   183  			w, h := parseSizes(attr.Val)
   184  			if w != h {
   185  				priority -= 100
   186  			} else if w == 32 {
   187  				priority += 400
   188  			} else if w == 64 {
   189  				priority += 300
   190  			} else if w >= 24 && w <= 128 {
   191  				priority += 200
   192  			} else if w == 16 {
   193  				priority += 100
   194  			}
   195  		}
   196  	}
   197  
   198  	if !isIcon || href == "" {
   199  		return "", -1
   200  	}
   201  	if !strings.Contains(href, "://") {
   202  		href = strings.TrimPrefix(href, "./")
   203  		href = strings.TrimPrefix(href, "/")
   204  		href = "https://" + domain + "/" + href
   205  	}
   206  	return href, priority
   207  }
   208  
   209  func parseSizes(val string) (int, int) {
   210  	parts := strings.Split(val, "x")
   211  	if len(parts) != 2 {
   212  		return 0, 0
   213  	}
   214  	w, err := strconv.Atoi(parts[0])
   215  	if err != nil {
   216  		return 0, 0
   217  	}
   218  	h, err := strconv.Atoi(parts[1])
   219  	if err != nil {
   220  		return 0, 0
   221  	}
   222  	return w, h
   223  }
   224  
   225  func downloadFavicon(domain string) (*Icon, error) {
   226  	icon, err := downloadIcon("https://" + domain + "/favicon.ico")
   227  	if err == nil {
   228  		return icon, nil
   229  	}
   230  	// Try again
   231  	time.Sleep(1 * time.Second)
   232  	return downloadIcon("https://" + domain + "/favicon.ico")
   233  }
   234  
   235  func downloadIcon(u string) (*Icon, error) {
   236  	req, err := http.NewRequest(http.MethodGet, u, nil)
   237  	if err != nil {
   238  		return nil, err
   239  	}
   240  	res, err := safehttp.DefaultClient.Do(req)
   241  	if err != nil {
   242  		return nil, err
   243  	}
   244  	defer res.Body.Close()
   245  	if res.StatusCode != http.StatusOK {
   246  		return nil, errors.New("Not status OK")
   247  	}
   248  	b, err := io.ReadAll(res.Body)
   249  	if err != nil {
   250  		return nil, err
   251  	}
   252  	if len(b) == 0 {
   253  		return nil, errors.New("Empty icon")
   254  	}
   255  	if len(b) > maxSize {
   256  		return nil, errors.New("Max size exceeded")
   257  	}
   258  	ico := Icon{
   259  		Mime: res.Header.Get(echo.HeaderContentType),
   260  		Body: b,
   261  	}
   262  	if strings.Split(ico.Mime, "/")[0] != "image" {
   263  		return nil, errors.New("Invalid mime-type")
   264  	}
   265  	return &ico, nil
   266  }