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  }