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 }