github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/screenshot/model.go (about) 1 // Copyright 2019 The WPT Dashboard Project. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package screenshot 6 7 import ( 8 "crypto/sha1" // nolint:gosec // TODO: Fix gosec lint error 9 "errors" 10 "fmt" 11 "io" 12 "time" 13 14 mapset "github.com/deckarep/golang-set" 15 16 "github.com/web-platform-tests/wpt.fyi/shared" 17 ) 18 19 // MaxItemsInResponse is the maximum number of hashes that will be returned 20 // when asked for recent screenshots. 21 const MaxItemsInResponse = 10000 22 23 var ( 24 // ErrInvalidHash is the error when a hash string is invalid. 25 ErrInvalidHash = errors.New("invalid hash string") 26 // ErrUnsupportedHashMethod is the error when the requested hash method 27 // is not supported. 28 ErrUnsupportedHashMethod = errors.New("hash method unsupported") 29 ) 30 31 // Screenshot is the entity stored in Datastore for a known screenshot hash and 32 // its metadata. 33 type Screenshot struct { 34 HashDigest string 35 HashMethod string 36 Labels []string 37 // These two fields can be left empty and will be filled by Store(). 38 Counter int 39 LastUsed time.Time 40 } 41 42 // NewScreenshot creates a new Screenshot with the given labels (empty labels 43 // are omitted). 44 func NewScreenshot(labels ...string) *Screenshot { 45 s := &Screenshot{} // nolint:exhaustruct // TODO: Fix exhaustruct lint error. 46 for _, l := range labels { 47 if l != "" { 48 s.Labels = append(s.Labels, l) 49 } 50 } 51 52 return s 53 } 54 55 // Hash returns the "HASH_METHOD:HASH_DIGEST" representation of the screenshot 56 // used in the API. 57 func (s *Screenshot) Hash() string { 58 return s.HashMethod + ":" + s.HashDigest 59 } 60 61 // Key returns the Datastore name key for this screenshot. 62 // 63 // Note that the order of HashDigest and HashMethod is inversed for better key 64 // space distribution. 65 func (s *Screenshot) Key() string { 66 return s.HashDigest + ":" + s.HashMethod 67 } 68 69 // SetHashFromFile hashes a file and sets the HashMethod and HashDigest fields. 70 func (s *Screenshot) SetHashFromFile(f io.Reader, hashMethod string) error { 71 if hashMethod != "sha1" { 72 return ErrUnsupportedHashMethod 73 } 74 // nolint:gosec // TODO: Fix gosec lint error (G401) 75 h := sha1.New() 76 if _, err := io.Copy(h, f); err != nil { 77 return err 78 } 79 s.HashMethod = hashMethod 80 s.HashDigest = fmt.Sprintf("%x", h.Sum(nil)) 81 82 return nil 83 } 84 85 // Store finalizes the struct and stores it to Datastore. 86 // 87 // Before writing to Datastore, this function will first check if there is an 88 // existing record with the same key; if so, it updates the struct to include 89 // all existing Labels and sets Counter to existing Counter + 1. Note that this 90 // is NOT done in a transaction for better performance (both Labels and Counter 91 // are auxiliary information that is OK to lose in a race condition). Lastly, 92 // LastUsed is populated with the current timestamp. 93 func (s *Screenshot) Store(ds shared.Datastore) error { 94 if s.HashDigest == "" || s.HashMethod == "" { 95 return ErrInvalidHash 96 } 97 key := ds.NewNameKey("Screenshot", s.Key()) 98 var oldS Screenshot 99 err := ds.Get(key, &oldS) 100 if err == nil { 101 // NO error, i.e., we found an existing entity. 102 s.Counter = oldS.Counter + 1 103 allLabels := append(s.Labels, oldS.Labels...) 104 s.Labels = shared.ToStringSlice(shared.NewSetFromStringSlice(allLabels)) 105 } 106 s.LastUsed = time.Now() 107 _, err = ds.Put(key, s) 108 109 return err 110 } 111 112 // RecentScreenshotHashes gets the most recently used screenshot hash strings 113 // based on the given arguments. 114 // 115 // We first try to find screenshots with all the four labels. When there are 116 // not enough, we remove the least important label and try again. 117 func RecentScreenshotHashes( 118 ds shared.Datastore, 119 browser, 120 browserVersion, 121 os, 122 osVersion string, 123 limit *int, 124 ) ([]string, error) { 125 totalLimit := MaxItemsInResponse 126 if limit != nil { 127 totalLimit = *limit 128 } 129 all := mapset.NewSet() 130 // The order is crucial: the least important label comes first. 131 rawLabels := []string{osVersion, browserVersion, os, browser} 132 // Remove empty labels (but keep the order). 133 var labels []string 134 for _, l := range rawLabels { 135 if l != "" { 136 labels = append(labels, l) 137 } 138 } 139 140 for all.Cardinality() < totalLimit { 141 query := ds.NewQuery("Screenshot") 142 for _, l := range labels { 143 query = query.Filter("Labels =", l) 144 } 145 query = query.Order("-LastUsed").Limit(totalLimit) 146 147 var hits []Screenshot 148 if _, err := ds.GetAll(query, &hits); err != nil { 149 return shared.ToStringSlice(all), err 150 } 151 for _, s := range hits { 152 all.Add(s.Hash()) 153 if all.Cardinality() == totalLimit { 154 break 155 } 156 } 157 158 if len(labels) == 0 { 159 break 160 } 161 labels = labels[1:] 162 } 163 164 return shared.ToStringSlice(all), nil 165 }