github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/server/main.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // Package main implements nup's App Engine server.
     5  package main
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"crypto/sha1"
    11  	"encoding/hex"
    12  	"encoding/json"
    13  	"fmt"
    14  	"io"
    15  	"math/rand"
    16  	"net"
    17  	"net/http"
    18  	"os"
    19  	"path/filepath"
    20  	"regexp"
    21  	"strconv"
    22  	"strings"
    23  	"sync"
    24  	"time"
    25  
    26  	"github.com/derat/nup/server/cache"
    27  	"github.com/derat/nup/server/config"
    28  	"github.com/derat/nup/server/cover"
    29  	"github.com/derat/nup/server/db"
    30  	"github.com/derat/nup/server/dump"
    31  	"github.com/derat/nup/server/query"
    32  	"github.com/derat/nup/server/ratelimit"
    33  	"github.com/derat/nup/server/stats"
    34  	"github.com/derat/nup/server/update"
    35  
    36  	"google.golang.org/appengine/v2"
    37  	"google.golang.org/appengine/v2/log"
    38  )
    39  
    40  const (
    41  	defaultDumpBatchSize = 100  // default size of batch of dumped entities
    42  	maxDumpBatchSize     = 5000 // max size of batch of dumped entities
    43  
    44  	maxCoverSize     = 800 // max size permitted in /cover scale requests
    45  	coverJPEGQuality = 90  // quality to use when encoding /cover replies
    46  )
    47  
    48  // forceUpdateFailures can be set by tests via /config to indicate that failures should be reported
    49  // for all user data updates (ratings, tags, plays).
    50  // TODO: This will only affect the instance that receives the /config request. For now,
    51  // test/dev_server.go passes --max_module_instances=1 to ensure that there's a single instance.
    52  var forceUpdateFailures = false
    53  
    54  // staticFileETags maps from a relative request path (e.g. "index.html") to a string containing
    55  // a quoted ETag header value for the file. We do this here instead of in getStaticFile() since
    56  // we don't need to hash the files that go into the bundle, just the bundle itself.
    57  var staticFileETags sync.Map
    58  
    59  func main() {
    60  	rand.Seed(time.Now().UnixNano())
    61  
    62  	// Get masks for various types of users.
    63  	norm := config.NormalUser
    64  	admin := config.AdminUser
    65  	guest := config.GuestUser
    66  	cron := config.CronUser
    67  
    68  	// Use a wrapper instead of calling http.HandleFunc directly to reduce the risk
    69  	// that a handler neglects checking that requests are authorized.
    70  	addHandler("/", http.MethodGet, norm|admin|guest, redirectUnauth, handleStatic)
    71  	addHandler("/manifest.json", http.MethodGet, norm|admin|guest, allowUnauth, handleStatic)
    72  
    73  	addHandler("/cover", http.MethodGet, norm|admin|guest, rejectUnauth, handleCover)
    74  	addHandler("/delete_song", http.MethodPost, admin, rejectUnauth, handleDeleteSong)
    75  	addHandler("/dump_song", http.MethodGet, norm|admin|guest, rejectUnauth, handleDumpSong)
    76  	addHandler("/export", http.MethodGet, norm|admin|guest, rejectUnauth, handleExport)
    77  	addHandler("/import", http.MethodPost, admin, rejectUnauth, handleImport)
    78  	addHandler("/now", http.MethodGet, norm|admin|guest, rejectUnauth, handleNow)
    79  	addHandler("/played", http.MethodPost, norm|admin, rejectUnauth, handlePlayed)
    80  	addHandler("/presets", http.MethodGet, norm|admin|guest, rejectUnauth, handlePresets)
    81  	addHandler("/query", http.MethodGet, norm|admin|guest, rejectUnauth, handleQuery)
    82  	addHandler("/rate_and_tag", http.MethodPost, norm|admin, rejectUnauth, handleRateAndTag)
    83  	addHandler("/reindex", http.MethodPost, admin, rejectUnauth, handleReindex)
    84  	addHandler("/song", http.MethodGet, norm|admin|guest, rejectUnauth, handleSong)
    85  	addHandler("/stats", http.MethodGet, norm|admin|guest|cron, rejectUnauth, handleStats)
    86  	addHandler("/tags", http.MethodGet, norm|admin|guest, rejectUnauth, handleTags)
    87  	addHandler("/user", http.MethodGet, norm|admin|guest, rejectUnauth, handleUser)
    88  
    89  	if appengine.IsDevAppServer() {
    90  		addHandler("/clear", http.MethodPost, admin, rejectUnauth, handleClear)
    91  		addHandler("/config", http.MethodPost, admin, rejectUnauth, handleConfig)
    92  		addHandler("/flush_cache", http.MethodPost, admin, rejectUnauth, handleFlushCache)
    93  	}
    94  
    95  	// Generate the index file and JS bundle so we're ready to serve them.
    96  	// We can't check whether minification is enabled at this point (since we don't
    97  	// have a context to load the config from datastore), so just optimistically
    98  	// assume that it is.
    99  	getStaticFile(indexFile, true)
   100  	getStaticFile(bundleFile, true)
   101  
   102  	// The google.golang.org/appengine packages are (were?) deprecated, and the official way forward
   103  	// is (was?) to use the non-App-Engine-specific cloud.google.com/go packages and call
   104  	// http.ListenAndServe(): https://cloud.google.com/appengine/docs/standard/go/go-differences
   105  	//
   106  	// However, this approach seems strictly worse in terms of usability, functionality, and cost:
   107  	//
   108  	// Log messages written via the log package in the Go standard library don't have a severity
   109  	// associated with them and don't get grouped with requests. It looks like the
   110  	// cloud.google.com/go/logging package can be used to write structured entries, but associating
   111  	// them with requests seems to require parsing X-Cloud-Trace-Context headers from incoming
   112  	// requests: https://cloud.google.com/appengine/docs/standard/go/writing-application-logs
   113  	// There are apparently third-party packages that can make this easier.
   114  	//
   115  	// Memcache support is completely dropped. The suggestion is to use Memorystore for Redis
   116  	// instead, but there's no free tier or shared instance:
   117  	// https://cloud.google.com/appengine/docs/standard/go/using-memorystore
   118  	// As of April 2020, the minimum cost (for a 1 GB Basic tier M1 instance) seems to be
   119  	// $0.049/hour, for about $35/month. I'm assuming that you can't get billed for a partial GB.
   120  	//
   121  	// Datastore seems to be pretty much the same, but it sounds like you need to run the datastore
   122  	// emulator now instead of using dev_appserver.py:
   123  	// https://cloud.google.com/datastore/docs/tools/datastore-emulator
   124  	// The emulator is still in beta, of course. You also need to explicitly initialize a client,
   125  	// which is a bit painful when you're dealing with individual requests and making datastore
   126  	// calls from different packages.
   127  	//
   128  	// The App Engine Mail and Blobstore APIs are apparently also getting killed off, but this app
   129  	// fortunately doesn't use them.
   130  	//
   131  	// Support for the appengine packages was initially dropped in the go112 runtime, but as of
   132  	// November 2021, it seems like this policy was maybe silently changed.
   133  	// https://cloud.google.com/appengine/docs/standard/go/go-differences now links to
   134  	// https://cloud.google.com/appengine/docs/standard/go/services/access, which explains how to
   135  	// continue using App Engine bundled services in Go 1.12+ (currently in a preview state).
   136  	//
   137  	// appengine.Main() needs to be called here so that appengine.NewContext() will work in the
   138  	// handlers.
   139  	appengine.Main()
   140  }
   141  
   142  func handleClear(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   143  	if err := update.ClearData(ctx); err != nil {
   144  		log.Errorf(ctx, "Clearing songs and plays failed: %v", err)
   145  		http.Error(w, err.Error(), http.StatusInternalServerError)
   146  		return
   147  	}
   148  	if err := stats.Clear(ctx); err != nil {
   149  		log.Errorf(ctx, "Clearing stats failed: %v", err)
   150  		http.Error(w, err.Error(), http.StatusInternalServerError)
   151  		return
   152  	}
   153  	if err := ratelimit.Clear(ctx); err != nil {
   154  		log.Errorf(ctx, "Clearing rate-limiting info failed: %v", err)
   155  		http.Error(w, err.Error(), http.StatusInternalServerError)
   156  		return
   157  	}
   158  	writeTextResponse(w, "ok")
   159  }
   160  
   161  func handleConfig(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   162  	forceUpdateFailures = r.FormValue("forceUpdateFailures") == "1"
   163  	writeTextResponse(w, "ok")
   164  }
   165  
   166  func handleCover(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   167  	fn := r.FormValue("filename")
   168  	if fn == "" {
   169  		log.Errorf(ctx, "Missing filename in cover request")
   170  		http.Error(w, "Missing filename", http.StatusBadRequest)
   171  		return
   172  	}
   173  	var size int64
   174  	if r.FormValue("size") != "" {
   175  		var ok bool
   176  		if size, ok = parseIntParam(ctx, w, r, "size"); !ok {
   177  			return
   178  		} else if size <= 0 || size > maxCoverSize {
   179  			log.Errorf(ctx, "Invalid cover size %v", size)
   180  			http.Error(w, "Invalid size", http.StatusBadRequest)
   181  			return
   182  		}
   183  	}
   184  	webp := r.FormValue("webp") == "1"
   185  
   186  	// cover.Scale will set the Content-Type header.
   187  	addLongCacheHeaders(w)
   188  	if err := cover.Scale(ctx, cfg.CoverBucket, cfg.CoverBaseURL, fn, int(size),
   189  		coverJPEGQuality, webp, w); err != nil {
   190  		log.Errorf(ctx, "Scaling cover %q failed: %v", fn, err)
   191  		if os.IsNotExist(err) {
   192  			http.Error(w, "Not found", http.StatusNotFound)
   193  		} else {
   194  			http.Error(w, "Scaling failed", http.StatusInternalServerError)
   195  		}
   196  		return
   197  	}
   198  }
   199  
   200  func handleDeleteSong(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   201  	id, ok := parseIntParam(ctx, w, r, "songId")
   202  	if !ok {
   203  		return
   204  	}
   205  	if err := update.DeleteSong(ctx, id); err != nil {
   206  		log.Errorf(ctx, "Deleting song %v failed: %v", id, err)
   207  		http.Error(w, err.Error(), http.StatusInternalServerError)
   208  	}
   209  	writeTextResponse(w, "ok")
   210  }
   211  
   212  func handleDumpSong(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   213  	id, ok := parseIntParam(ctx, w, r, "songId")
   214  	if !ok {
   215  		return
   216  	}
   217  
   218  	s, err := dump.SingleSong(ctx, id)
   219  	if err != nil {
   220  		log.Errorf(ctx, "Dumping song %v failed: %v", id, err)
   221  		http.Error(w, err.Error(), http.StatusInternalServerError)
   222  		return
   223  	}
   224  
   225  	b, err := json.Marshal(s)
   226  	if err != nil {
   227  		log.Errorf(ctx, "Marshaling song %v failed: %v", id, err)
   228  		http.Error(w, err.Error(), http.StatusInternalServerError)
   229  		return
   230  	}
   231  	var out bytes.Buffer
   232  	json.Indent(&out, b, "", "  ")
   233  	writeTextResponse(w, out.String())
   234  }
   235  
   236  func handleExport(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   237  	var max int64 = defaultDumpBatchSize
   238  	if len(r.FormValue("max")) > 0 {
   239  		var ok bool
   240  		if max, ok = parseIntParam(ctx, w, r, "max"); !ok {
   241  			return
   242  		}
   243  	}
   244  	if max > maxDumpBatchSize {
   245  		max = maxDumpBatchSize
   246  	}
   247  
   248  	w.Header().Set("Content-Type", "text/plain")
   249  	e := json.NewEncoder(w)
   250  
   251  	var objectPtrs []interface{}
   252  	var nextCursor string
   253  	var err error
   254  
   255  	switch r.FormValue("type") {
   256  	case "song":
   257  		var lastMod time.Time
   258  		if len(r.FormValue("minLastModifiedNsec")) > 0 {
   259  			if ns, ok := parseIntParam(ctx, w, r, "minLastModifiedNsec"); !ok {
   260  				return
   261  			} else if ns > 0 {
   262  				lastMod = time.Unix(0, ns)
   263  			}
   264  		}
   265  
   266  		cursor := r.FormValue("cursor")
   267  		deleted := r.FormValue("deleted") == "1"
   268  
   269  		var songs []db.Song
   270  		if songs, nextCursor, err = dump.Songs(ctx, max, cursor, deleted, lastMod); err != nil {
   271  			log.Errorf(ctx, "Dumping songs failed: %v", err)
   272  			http.Error(w, err.Error(), http.StatusInternalServerError)
   273  			return
   274  		}
   275  
   276  		omit := make(map[string]bool)
   277  		for _, s := range strings.Split(r.FormValue("omit"), ",") {
   278  			omit[s] = true
   279  		}
   280  		objectPtrs = make([]interface{}, len(songs))
   281  		for i := range songs {
   282  			s := &songs[i]
   283  			if omit["coverFilename"] {
   284  				s.CoverFilename = ""
   285  			}
   286  			if omit["plays"] {
   287  				s.Plays = nil
   288  			}
   289  			if omit["sha1"] {
   290  				s.SHA1 = ""
   291  			}
   292  			objectPtrs[i] = s
   293  		}
   294  	case "play":
   295  		var plays []db.PlayDump
   296  		plays, nextCursor, err = dump.Plays(ctx, max, r.FormValue("cursor"))
   297  		if err != nil {
   298  			log.Errorf(ctx, "Dumping plays failed: %v", err)
   299  			http.Error(w, err.Error(), http.StatusInternalServerError)
   300  			return
   301  		}
   302  		objectPtrs = make([]interface{}, len(plays))
   303  		for i := range plays {
   304  			objectPtrs[i] = &plays[i]
   305  		}
   306  	default:
   307  		http.Error(w, "Invalid type", http.StatusBadRequest)
   308  		return
   309  	}
   310  
   311  	for i := 0; i < len(objectPtrs); i++ {
   312  		if err = e.Encode(objectPtrs[i]); err != nil {
   313  			log.Errorf(ctx, "Encoding object failed: %v", err)
   314  			http.Error(w, err.Error(), http.StatusInternalServerError)
   315  			return
   316  		}
   317  	}
   318  	if len(nextCursor) > 0 {
   319  		if err = e.Encode(nextCursor); err != nil {
   320  			log.Errorf(ctx, "Encoding cursor failed: %v", err)
   321  			http.Error(w, err.Error(), http.StatusInternalServerError)
   322  			return
   323  		}
   324  	}
   325  }
   326  
   327  func handleFlushCache(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   328  	if err := query.FlushCache(ctx, cache.Memcache); err != nil {
   329  		log.Errorf(ctx, "Flushing query cache from memcache failed: %v", err)
   330  		http.Error(w, err.Error(), http.StatusInternalServerError)
   331  		return
   332  	}
   333  	if r.FormValue("onlyMemcache") != "1" {
   334  		if err := query.FlushCache(ctx, cache.Datastore); err != nil {
   335  			log.Errorf(ctx, "Flushing query cache from datastore failed: %v", err)
   336  			http.Error(w, err.Error(), http.StatusInternalServerError)
   337  			return
   338  		}
   339  	}
   340  	writeTextResponse(w, "ok")
   341  }
   342  
   343  func handleImport(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   344  	dataPolicy := update.PreserveUserData
   345  	if r.FormValue("replaceUserData") == "1" {
   346  		dataPolicy = update.ReplaceUserData
   347  	}
   348  
   349  	keyType := update.UpdateBySHA1
   350  	if r.FormValue("useFilenames") == "1" {
   351  		keyType = update.UpdateByFilename
   352  	}
   353  
   354  	var delay time.Duration
   355  	if len(r.FormValue("updateDelayNsec")) > 0 {
   356  		if ns, ok := parseIntParam(ctx, w, r, "updateDelayNsec"); !ok {
   357  			return
   358  		} else {
   359  			delay = time.Nanosecond * time.Duration(ns)
   360  		}
   361  	}
   362  
   363  	numSongs := 0
   364  	d := json.NewDecoder(r.Body)
   365  	for {
   366  		s := &db.Song{}
   367  		if err := d.Decode(s); err == io.EOF {
   368  			break
   369  		} else if err != nil {
   370  			log.Errorf(ctx, "Decode song failed: %v", err)
   371  			http.Error(w, err.Error(), http.StatusBadRequest)
   372  			return
   373  		}
   374  		if err := update.UpdateOrInsertSong(ctx, s, dataPolicy, keyType, delay); err != nil {
   375  			log.Errorf(ctx, "Update song with SHA1 %v failed: %v", s.SHA1, err)
   376  			http.Error(w, err.Error(), http.StatusInternalServerError)
   377  			return
   378  		}
   379  		numSongs++
   380  	}
   381  	if err := query.FlushCacheForUpdate(ctx, query.MetadataUpdate); err != nil {
   382  		log.Errorf(ctx, "Flushing query cache for update failed: %v", err)
   383  		http.Error(w, err.Error(), http.StatusInternalServerError)
   384  	}
   385  	log.Debugf(ctx, "Updated %v song(s)", numSongs)
   386  	writeTextResponse(w, "ok")
   387  }
   388  
   389  func handleNow(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   390  	writeTextResponse(w, strconv.FormatInt(time.Now().UnixNano(), 10))
   391  }
   392  
   393  func handlePlayed(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   394  	id, ok := parseIntParam(ctx, w, r, "songId")
   395  	if !ok {
   396  		return
   397  	}
   398  	startTime, ok := parseDateParam(ctx, w, r, "startTime")
   399  	if !ok {
   400  		return
   401  	}
   402  
   403  	if forceUpdateFailures && appengine.IsDevAppServer() {
   404  		http.Error(w, "Returning an error, as requested", http.StatusInternalServerError)
   405  		return
   406  	}
   407  
   408  	// SplitHostPort removes brackets for us.
   409  	ip, _, err := net.SplitHostPort(r.RemoteAddr)
   410  	if err != nil {
   411  		// Drop the trailing colon and port number. We can't just split on ':' and
   412  		// take the first item since we may get an IPv6 address like "[::1]:12345".
   413  		ip = regexp.MustCompile(":\\d+$").ReplaceAllString(r.RemoteAddr, "")
   414  	}
   415  
   416  	if err := update.AddPlay(ctx, id, startTime, ip); err != nil {
   417  		log.Errorf(ctx, "Recording play of %v at %v failed: %v", id, startTime, err)
   418  		http.Error(w, err.Error(), http.StatusInternalServerError)
   419  	}
   420  	writeTextResponse(w, "ok")
   421  }
   422  
   423  func handlePresets(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   424  	presets := cfg.Presets
   425  	if user, _ := cfg.GetUser(r); user != nil && len(user.Presets) > 0 {
   426  		presets = user.Presets
   427  	}
   428  	writeJSONResponse(w, presets)
   429  }
   430  
   431  func handleQuery(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   432  	var flags query.SongsFlags
   433  	if r.FormValue("cacheOnly") == "1" {
   434  		flags |= query.CacheOnly
   435  	}
   436  	if v := r.FormValue("fallback"); v == "force" {
   437  		flags |= query.ForceFallback
   438  	} else if v == "never" {
   439  		flags |= query.NoFallback
   440  	}
   441  
   442  	q := query.SongQuery{
   443  		Artist:               r.FormValue("artist"),
   444  		Title:                r.FormValue("title"),
   445  		Album:                r.FormValue("album"),
   446  		AlbumID:              r.FormValue("albumId"),
   447  		Filename:             r.FormValue("filename"),
   448  		Keywords:             strings.Fields(r.FormValue("keywords")),
   449  		MaxPlays:             -1,
   450  		Shuffle:              r.FormValue("shuffle") == "1",
   451  		OrderByLastStartTime: r.FormValue("orderByLastPlayed") == "1",
   452  	}
   453  
   454  	if r.FormValue("firstTrack") == "1" {
   455  		q.Track = 1
   456  		q.Disc = 1
   457  	}
   458  
   459  	if r.FormValue("rating") != "" {
   460  		if v, ok := parseIntParam(ctx, w, r, "rating"); !ok {
   461  			return
   462  		} else {
   463  			q.Rating = int(v)
   464  		}
   465  	} else if r.FormValue("minRating") != "" {
   466  		if v, ok := parseIntParam(ctx, w, r, "minRating"); !ok {
   467  			return
   468  		} else {
   469  			q.MinRating = int(v)
   470  		}
   471  	} else if r.FormValue("maxRating") != "" {
   472  		if v, ok := parseIntParam(ctx, w, r, "maxRating"); !ok {
   473  			return
   474  		} else {
   475  			q.MaxRating = int(v)
   476  		}
   477  	} else if r.FormValue("unrated") == "1" {
   478  		q.Unrated = true
   479  	}
   480  
   481  	if len(r.FormValue("maxPlays")) > 0 {
   482  		var ok bool
   483  		if q.MaxPlays, ok = parseIntParam(ctx, w, r, "maxPlays"); !ok {
   484  			return
   485  		}
   486  	}
   487  
   488  	for name, dst := range map[string]*time.Time{
   489  		"minDate":        &q.MinDate,
   490  		"maxDate":        &q.MaxDate,
   491  		"minFirstPlayed": &q.MinFirstStartTime,
   492  		"maxLastPlayed":  &q.MaxLastStartTime,
   493  	} {
   494  		if len(r.FormValue(name)) > 0 {
   495  			var ok bool
   496  			if *dst, ok = parseDateParam(ctx, w, r, name); !ok {
   497  				return
   498  			}
   499  		}
   500  	}
   501  
   502  	for _, t := range strings.Fields(r.FormValue("tags")) {
   503  		if t[0] == '-' {
   504  			q.NotTags = append(q.NotTags, t[1:len(t)])
   505  		} else {
   506  			q.Tags = append(q.Tags, t)
   507  		}
   508  	}
   509  	if user, _ := cfg.GetUser(r); user != nil && len(user.ExcludedTags) > 0 {
   510  		q.NotTags = append(q.NotTags, user.ExcludedTags...)
   511  	}
   512  
   513  	songs, err := query.Songs(ctx, &q, flags)
   514  	if err != nil {
   515  		log.Errorf(ctx, "Unable to query songs: %v", err)
   516  		http.Error(w, err.Error(), http.StatusInternalServerError)
   517  		return
   518  	}
   519  	writeJSONResponse(w, songs)
   520  }
   521  
   522  func handleRateAndTag(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   523  	id, ok := parseIntParam(ctx, w, r, "songId")
   524  	if !ok {
   525  		return
   526  	}
   527  
   528  	var delay time.Duration
   529  	if len(r.FormValue("updateDelayNsec")) > 0 {
   530  		if ns, ok := parseIntParam(ctx, w, r, "updateDelayNsec"); !ok {
   531  			return
   532  		} else {
   533  			delay = time.Nanosecond * time.Duration(ns)
   534  		}
   535  	}
   536  
   537  	var hasRating bool
   538  	var rating int
   539  	var tags []string
   540  	if _, ok := r.Form["rating"]; ok {
   541  		if v, ok := parseIntParam(ctx, w, r, "rating"); !ok {
   542  			return
   543  		} else {
   544  			rating = int(v)
   545  		}
   546  		hasRating = true
   547  		if rating < 0 {
   548  			rating = 0
   549  		} else if rating > 5 {
   550  			rating = 5
   551  		}
   552  	}
   553  	if _, ok := r.Form["tags"]; ok {
   554  		tags = strings.Fields(r.FormValue("tags"))
   555  	}
   556  	if !hasRating && tags == nil {
   557  		http.Error(w, "No rating or tags supplied", http.StatusBadRequest)
   558  		return
   559  	}
   560  
   561  	if forceUpdateFailures && appengine.IsDevAppServer() {
   562  		http.Error(w, "Returning an error, as requested", http.StatusInternalServerError)
   563  		return
   564  	}
   565  
   566  	if err := update.SetRatingAndTags(ctx, id, hasRating, rating, tags, delay); err != nil {
   567  		log.Errorf(ctx, "Rating/tagging song %d failed: %v", id, err)
   568  		http.Error(w, err.Error(), http.StatusInternalServerError)
   569  		return
   570  	}
   571  	writeTextResponse(w, "ok")
   572  }
   573  
   574  func handleReindex(ctx context.Context, cfg *config.Config, w http.ResponseWriter, r *http.Request) {
   575  	cursor, scanned, updated, err := update.ReindexSongs(ctx, r.FormValue("cursor"))
   576  	if err != nil {
   577  		log.Errorf(ctx, "Reindexing songs failed: %v", err)
   578  		http.Error(w, err.Error(), http.StatusInternalServerError)
   579  		return
   580  	}
   581  	writeJSONResponse(w, struct {
   582  		Scanned int    `json:"scanned"`
   583  		Updated int    `json:"updated"`
   584  		Cursor  string `json:"cursor"`
   585  	}{
   586  		scanned, updated, cursor,
   587  	})
   588  }
   589  
   590  // The existence of this endpoint makes me extremely unhappy, but it seems necessary due to
   591  // bad interactions between Google Cloud Storage, the Web Audio API, and CORS:
   592  //
   593  //  - The <audio> element doesn't allow its volume to be set above 1.0, so the web client needs to
   594  //    use GainNode from the Web Audio API to amplify quiet tracks.
   595  //  - <audio> seems to support playing cross-origin data as long as you don't look at it, but the
   596  //    Web Audio API replaces cross-origin data with zeros:
   597  //    https://www.w3.org/TR/webaudio/#MediaElementAudioSourceOptions-security
   598  //  - You can use CORS to get around that, but the GCS authenticated browser endpoint
   599  //    (storage.cloud.google.com) doesn't allow CORS requests:
   600  //    https://cloud.google.com/storage/docs/cross-origin
   601  //
   602  // So, I'm copying songs through App Engine instead of letting GCS serve them so they won't be
   603  // cross-origin.
   604  //
   605  // The Web Audio part of this is particularly frustrating, as the JS doesn't actually need to look
   606  // at the audio data; it just need to amplify it.
   607  func handleSong(ctx context.Context, cfg *config.Config, w http.ResponseWriter, req *http.Request) {
   608  	if max := cfg.MaxGuestSongRequestsPerHour; max > 0 {
   609  		if utype, user := cfg.GetUserType(req); utype == config.GuestUser {
   610  			// TODO: This should probably handle range requests differently.
   611  			// Maybe we should just count requests that ask for the first byte?
   612  			if err := ratelimit.Attempt(ctx, user, time.Now(), max, time.Hour); err != nil {
   613  				log.Errorf(ctx, "Song request from %q rejected: %v", user, err)
   614  				http.Error(w, "Guest rate limit exceeded", http.StatusTooManyRequests)
   615  				return
   616  			}
   617  		}
   618  	}
   619  
   620  	fn := req.FormValue("filename")
   621  	if fn == "" {
   622  		log.Errorf(ctx, "Missing filename in song data request")
   623  		http.Error(w, "Missing filename", http.StatusBadRequest)
   624  		return
   625  	}
   626  
   627  	r, err := openSong(ctx, cfg, fn)
   628  	if err != nil {
   629  		log.Errorf(ctx, "Opening song %q failed: %v", fn, err)
   630  		if os.IsNotExist(err) {
   631  			http.Error(w, "Not found", http.StatusNotFound)
   632  		} else {
   633  			http.Error(w, fmt.Sprintf("Failed opening song: %v", err), http.StatusInternalServerError)
   634  		}
   635  		return
   636  	}
   637  	defer r.Close()
   638  
   639  	addLongCacheHeaders(w)
   640  
   641  	if sr, ok := r.(songReader); ok {
   642  		if err := sendSong(ctx, req, w, sr); err != nil {
   643  			log.Errorf(ctx, "Sending song %q failed: %v", fn, err)
   644  		}
   645  	} else {
   646  		// Just send a 200 with the whole file if we're getting it over HTTP rather than from GCS.
   647  		// This is only used by tests.
   648  		w.Header().Set("Content-Type", "audio/mpeg")
   649  		if _, err := io.Copy(w, r); err != nil {
   650  			// Too late to report an HTTP error.
   651  			log.Errorf(ctx, "Sending song %q failed: %v", fn, err)
   652  		}
   653  	}
   654  }
   655  
   656  func handleStatic(ctx context.Context, cfg *config.Config, w http.ResponseWriter, req *http.Request) {
   657  	p := filepath.Clean(req.URL.Path)
   658  	if p == "/" {
   659  		p = "index.html"
   660  	} else if strings.HasPrefix(p, "/") {
   661  		p = p[1:]
   662  	}
   663  
   664  	minify := cfg.Minify == nil || *cfg.Minify
   665  	if strings.HasSuffix(p, ".ts") {
   666  		// Serving TypeScript files doesn't make sense.
   667  		http.Error(w, "Not found", http.StatusNotFound)
   668  	} else if b, err := getStaticFile(p, minify); os.IsNotExist(err) {
   669  		http.Error(w, "Not found", http.StatusNotFound)
   670  	} else if err != nil {
   671  		log.Errorf(ctx, "Reading %q failed: %v", p, err)
   672  		http.Error(w, err.Error(), http.StatusInternalServerError)
   673  	} else {
   674  		var etag string
   675  		if v, ok := staticFileETags.Load(p); ok {
   676  			etag = v.(string)
   677  		} else {
   678  			sum := sha1.Sum(b)
   679  			etag = fmt.Sprintf(`"%s"`, hex.EncodeToString(sum[:]))
   680  			staticFileETags.Store(p, etag)
   681  		}
   682  		w.Header().Set("ETag", etag)
   683  
   684  		// App Engine seems to always report static file mtimes as 1980:
   685  		//  https://issuetracker.google.com/issues/168399701
   686  		//  https://stackoverflow.com/questions/63813692
   687  		//  https://github.com/GoogleChrome/web.dev/issues/3913
   688  		http.ServeContent(w, req, filepath.Base(p), time.Time{}, bytes.NewReader(b))
   689  	}
   690  }
   691  
   692  func handleStats(ctx context.Context, cfg *config.Config, w http.ResponseWriter, req *http.Request) {
   693  	// Updates would be better suited to POST than to GET, but App Engine cron uses GET per
   694  	// https://cloud.google.com/appengine/docs/standard/go/scheduling-jobs-with-cron-yaml.
   695  	if req.FormValue("update") == "1" {
   696  		// Don't let guest users update stats.
   697  		if utype, user := cfg.GetUserType(req); utype == config.GuestUser {
   698  			log.Errorf(ctx, "Rejecting stats update from guest user %q", user)
   699  			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
   700  			return
   701  		}
   702  		if err := stats.Update(ctx); err != nil {
   703  			log.Errorf(ctx, "Updating stats failed: %v", err)
   704  			http.Error(w, err.Error(), http.StatusInternalServerError)
   705  			return
   706  		}
   707  		writeTextResponse(w, "ok")
   708  		return
   709  	}
   710  
   711  	stats, err := stats.Get(ctx)
   712  	if err != nil {
   713  		log.Errorf(ctx, "Getting stats failed: %v", err)
   714  		http.Error(w, err.Error(), http.StatusInternalServerError)
   715  		return
   716  	}
   717  	writeJSONResponse(w, stats)
   718  }
   719  
   720  func handleTags(ctx context.Context, cfg *config.Config, w http.ResponseWriter, req *http.Request) {
   721  	tags, err := query.Tags(ctx, req.FormValue("requireCache") == "1")
   722  	if err != nil {
   723  		log.Errorf(ctx, "Querying tags failed: %v", err)
   724  		http.Error(w, err.Error(), http.StatusInternalServerError)
   725  		return
   726  	}
   727  	// Filter out excluded tags.
   728  	if user, _ := cfg.GetUser(req); user != nil && len(user.ExcludedTags) > 0 {
   729  		excluded := make(map[string]struct{}, len(user.ExcludedTags))
   730  		for _, tag := range user.ExcludedTags {
   731  			excluded[tag] = struct{}{}
   732  		}
   733  		var num int
   734  		for _, tag := range tags {
   735  			if _, ok := excluded[tag]; !ok {
   736  				tags[num] = tag
   737  				num++
   738  			}
   739  		}
   740  		tags = tags[:num]
   741  	}
   742  	writeJSONResponse(w, tags)
   743  }
   744  
   745  func handleUser(ctx context.Context, cfg *config.Config, w http.ResponseWriter, req *http.Request) {
   746  	user, name := cfg.GetUser(req)
   747  	if user == nil {
   748  		// This handler shouldn't have been called if the request wasn't from a valid user.
   749  		log.Errorf(ctx, "Invalid user %q", name)
   750  		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
   751  		return
   752  	}
   753  	writeJSONResponse(w, user)
   754  }