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 }