package server import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "unicode/utf8" "github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/topdeck/internal/imgcache" "github.com/cdnutter/topdeck/internal/providers" "github.com/cdnutter/topdeck/internal/store" ) // ---- lists ---- func (s *Server) apiLists(w http.ResponseWriter, r *http.Request) { lists, err := s.store.Lists(r.Context(), true) if err != nil { s.writeErr(w, err) return } if lists == nil { lists = []store.List{} } writeJSON(w, http.StatusOK, lists) } func (s *Server) apiCreateList(w http.ResponseWriter, r *http.Request) { var l store.List if err := decodeJSON(r, &l); err != nil { s.writeErr(w, err) return } l, err := s.store.CreateList(r.Context(), l) if err != nil { s.writeErr(w, err) return } writeJSON(w, http.StatusCreated, l) } func (s *Server) apiUpdateList(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { s.writeErr(w, err) return } var l store.List if err := decodeJSON(r, &l); err != nil { s.writeErr(w, err) return } l.ID = id l, err = s.store.UpdateList(r.Context(), l) if err != nil { s.writeErr(w, err) return } writeJSON(w, http.StatusOK, l) } func (s *Server) apiDeleteList(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { s.writeErr(w, err) return } images, err := s.store.ListImages(r.Context(), id) if err != nil { s.writeErr(w, err) return } if err := s.store.DeleteList(r.Context(), id); err != nil { s.writeErr(w, err) return } for _, img := range images { s.releaseImage(r.Context(), img) } w.WriteHeader(http.StatusNoContent) } func (s *Server) apiReorderLists(w http.ResponseWriter, r *http.Request) { var body struct { IDs []int64 `json:"ids"` } if err := decodeJSON(r, &body); err != nil { s.writeErr(w, err) return } if err := s.store.ReorderLists(r.Context(), body.IDs); err != nil { s.writeErr(w, err) return } w.WriteHeader(http.StatusNoContent) } // ---- items ---- func (s *Server) apiItems(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { s.writeErr(w, err) return } offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 || limit > 500 { limit = 100 } items, err := s.store.Items(r.Context(), id, max(0, offset), limit) if err != nil { s.writeErr(w, err) return } writeJSON(w, http.StatusOK, items) } type itemInput struct { Title string `json:"title"` Subtitle string `json:"subtitle"` Year string `json:"year"` Note string `json:"note"` ExternalURL string `json:"externalUrl"` Extra map[string]string `json:"extra"` ImageURL string `json:"imageUrl"` ClearImage bool `json:"clearImage"` // Only used when adding. Provider string `json:"provider"` ProviderID string `json:"providerId"` Rank int `json:"rank"` Details bool `json:"details"` Force bool `json:"force"` } type itemResponse struct { Item store.Item `json:"item"` Warning string `json:"warning,omitempty"` } func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() listID, err := pathID(r) if err != nil { s.writeErr(w, err) return } var in itemInput if err := decodeJSON(r, &in); err != nil { s.writeErr(w, err) return } list, err := s.store.ListByID(ctx, listID) if err != nil { s.writeErr(w, err) return } if list.ItemCount >= list.MaxItems { s.writeErr(w, store.ValidationError{Msg: fmt.Sprintf("This list is full (%d of %d). Increase its size or remove an item first.", list.ItemCount, list.MaxItems)}) return } if !in.Force { if dup, err := s.store.FindItemByProvider(ctx, listID, in.Provider, in.ProviderID); err == nil { writeJSON(w, http.StatusConflict, map[string]any{ "error": fmt.Sprintf("“%s” is already on this list at #%d.", dup.Title, dup.Rank), "rank": dup.Rank, }) return } } res := providers.Result{ Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle, Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra, } if in.Details && in.Provider != "" { res = s.fillDetails(ctx, list.Category, res) } it := store.Item{ ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year, Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL, Note: in.Note, Extra: res.Extra, } var warning string if res.ImageURL != "" { name, err := s.fetchImage(ctx, res.ImageURL) if err != nil { warning = "Added without artwork: " + err.Error() } else { it.Image, it.ImageSource = name, res.ImageURL } } saved, err := s.store.AddItem(ctx, it, in.Rank) if err != nil { s.releaseImage(ctx, it.Image) s.writeErr(w, err) return } writeJSON(w, http.StatusCreated, itemResponse{Item: saved, Warning: warning}) } // fillDetails asks the provider for richer metadata. Failures are logged // and the search result is used as-is. func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result { p, err := s.providers.For(ctx, category) if err != nil || p.Name() != res.Provider { return res } d, ok := p.(providers.Detailer) if !ok { return res } dctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() full, err := d.Details(dctx, res) if err != nil { s.log.Warn("fetching details", "provider", res.Provider, "id", res.ProviderID, "err", err) return res } return full } func (s *Server) fetchImage(ctx context.Context, url string) (string, error) { fctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() return s.images.Fetch(fctx, url) } // releaseImage deletes a cached image once nothing references it. func (s *Server) releaseImage(ctx context.Context, name string) { if name == "" { return } inUse, err := s.store.ImageInUse(ctx, name) if err != nil || inUse { return } if err := s.images.Remove(name); err != nil { s.log.Warn("removing image", "name", name, "err", err) } } func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := pathID(r) if err != nil { s.writeErr(w, err) return } var in itemInput if err := decodeJSON(r, &in); err != nil { s.writeErr(w, err) return } cur, err := s.store.ItemByID(ctx, id) if err != nil { s.writeErr(w, err) return } it := cur it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL if in.Extra != nil { it.Extra = in.Extra } if in.ClearImage { it.Image, it.ImageSource = "", "" } if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource { name, err := s.fetchImage(ctx, url) if err != nil { s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()}) return } it.Image, it.ImageSource = name, url } saved, err := s.store.UpdateItem(ctx, it) if err != nil { if it.Image != cur.Image { s.releaseImage(ctx, it.Image) } s.writeErr(w, err) return } if cur.Image != saved.Image { s.releaseImage(ctx, cur.Image) } writeJSON(w, http.StatusOK, itemResponse{Item: saved}) } func (s *Server) saveUpload(r *http.Request) (string, error) { r.Body = http.MaxBytesReader(nil, r.Body, imgcache.MaxBytes+1<<20) f, _, err := r.FormFile("file") if err != nil { return "", store.ValidationError{Msg: "Choose an image file (up to 15 MB)."} } defer f.Close() name, err := s.images.Save(f) if err != nil && !errors.Is(err, imgcache.ErrNotImage) { return "", store.ValidationError{Msg: err.Error()} } return name, err } func (s *Server) apiUploadItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, err := pathID(r) if err != nil { s.writeErr(w, err) return } cur, err := s.store.ItemByID(ctx, id) if err != nil { s.writeErr(w, err) return } name, err := s.saveUpload(r) if err != nil { s.writeErr(w, err) return } it := cur it.Image, it.ImageSource = name, "upload" saved, err := s.store.UpdateItem(ctx, it) if err != nil { s.releaseImage(ctx, name) s.writeErr(w, err) return } if cur.Image != name { s.releaseImage(ctx, cur.Image) } writeJSON(w, http.StatusOK, itemResponse{Item: saved}) } func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { s.writeErr(w, err) return } var body struct { Rank int `json:"rank"` } if err := decodeJSON(r, &body); err != nil { s.writeErr(w, err) return } if err := s.store.MoveItem(r.Context(), id, body.Rank); err != nil { s.writeErr(w, err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { s.writeErr(w, err) return } it, err := s.store.DeleteItem(r.Context(), id) if err != nil { s.writeErr(w, err) return } s.releaseImage(r.Context(), it.Image) w.WriteHeader(http.StatusNoContent) } // ---- search ---- type searchResult struct { providers.Result ExistingRank int `json:"existingRank,omitempty"` } func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() q := strings.TrimSpace(r.URL.Query().Get("q")) category := r.URL.Query().Get("category") if q == "" || utf8.RuneCountInString(q) > 200 { writeError(w, http.StatusBadRequest, "Type something to search for.") return } p, err := s.providers.For(ctx, category) if err != nil { s.writeErr(w, err) return } sctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() results, err := p.Search(sctx, q) if err != nil { s.log.Warn("search failed", "provider", p.Name(), "err", err) writeError(w, http.StatusBadGateway, fmt.Sprintf("%s search failed: %v", p.Label(), err)) return } listID, _ := strconv.ParseInt(r.URL.Query().Get("list"), 10, 64) out := make([]searchResult, 0, len(results)) for _, res := range results { sr := searchResult{Result: res} if listID > 0 { if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil { sr.ExistingRank = it.Rank } } out = append(out, sr) } writeJSON(w, http.StatusOK, map[string]any{"provider": p.Name(), "label": p.Label(), "results": out}) } // ---- settings ---- type secretStatus struct { Set bool `json:"set"` Hint string `json:"hint"` Locked bool `json:"locked"` // provided by an environment variable } var secretKeys = []string{store.SettingTMDBKey, store.SettingTVDBKey, store.SettingTVDBPin, store.SettingIGDBClientID, store.SettingIGDBClientSecret} func (s *Server) envSecret(key string) string { switch key { case store.SettingTMDBKey: return s.cfg.TMDBKey case store.SettingTVDBKey: return s.cfg.TVDBKey case store.SettingTVDBPin: return s.cfg.TVDBPin case store.SettingIGDBClientID: return s.cfg.IGDBClientID case store.SettingIGDBClientSecret: return s.cfg.IGDBClientSecret } return "" } func hint(v string) string { if len(v) <= 4 { return strings.Repeat("•", len(v)) } return "••••" + v[len(v)-4:] } func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) { set, err := s.store.Settings(r.Context()) if err != nil { s.writeErr(w, err) return } site := s.site(r.Context()) secrets := map[string]secretStatus{} for _, k := range secretKeys { v, locked := set[k], false if env := s.envSecret(k); env != "" { v, locked = env, true } secrets[k] = secretStatus{Set: v != "", Hint: hint(v), Locked: locked} } u, _ := s.currentUser(r) writeJSON(w, http.StatusOK, map[string]any{ "siteTitle": set[store.SettingSiteTitle], "ownerName": site.OwnerName, "tagline": site.Tagline, "avatar": site.AvatarURL, "theme": site.Theme, "customCss": set[store.SettingCustomCSS], "previewCount": site.PreviewCount, "showNotes": site.ShowNotes, "musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"), "tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"), "secrets": secrets, "themes": Themes, "username": u.Username, }) } func orDefault(v, def string) string { if v == "" { return def } return v } func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) { var in map[string]any if err := decodeJSON(r, &in); err != nil { s.writeErr(w, err) return } out := map[string]string{} str := func(field, key string, maxLen int) error { v, ok := in[field] if !ok { return nil } sv, ok := v.(string) if !ok { return store.ValidationError{Msg: field + " must be text"} } sv = strings.TrimSpace(sv) if len(sv) > maxLen { return store.ValidationError{Msg: fmt.Sprintf("%s is too long (max %d characters)", field, maxLen)} } out[key] = sv return nil } checks := []error{ str("siteTitle", store.SettingSiteTitle, 100), str("ownerName", store.SettingOwnerName, 100), str("tagline", store.SettingTagline, 300), str("theme", store.SettingTheme, 50), str("customCss", store.SettingCustomCSS, 200_000), str("musicProvider", store.SettingMusicProvider, 20), str("tvProvider", store.SettingTVProvider, 20), } for _, k := range secretKeys { checks = append(checks, str(k, k, 500)) } for _, err := range checks { if err != nil { s.writeErr(w, err) return } } if v, ok := out[store.SettingTheme]; ok && !validTheme(v) { s.writeErr(w, store.ValidationError{Msg: "Unknown theme"}) return } if v, ok := out[store.SettingMusicProvider]; ok && v != "deezer" && v != "musicbrainz" { s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"}) return } if v, ok := out[store.SettingTVProvider]; ok && v != "tvdb" && v != "tmdb" { s.writeErr(w, store.ValidationError{Msg: "Unknown TV provider"}) return } if v, ok := in["previewCount"]; ok { n, ok := v.(float64) if !ok || n < 0 || n > 50 || n != float64(int(n)) { s.writeErr(w, store.ValidationError{Msg: "Home page preview must be between 0 and 50 items"}) return } out[store.SettingPreviewCount] = strconv.Itoa(int(n)) } if v, ok := in["showNotes"]; ok { b, ok := v.(bool) if !ok { s.writeErr(w, store.ValidationError{Msg: "showNotes must be true or false"}) return } out[store.SettingShowNotes] = strconv.FormatBool(b) } if err := s.store.SetSettings(r.Context(), out); err != nil { s.writeErr(w, err) return } s.apiSettings(w, r) } func (s *Server) apiUploadAvatar(w http.ResponseWriter, r *http.Request) { ctx := r.Context() name, err := s.saveUpload(r) if err != nil { s.writeErr(w, err) return } set, err := s.store.Settings(ctx) if err != nil { s.writeErr(w, err) return } old := set[store.SettingAvatar] if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: name}); err != nil { s.writeErr(w, err) return } if old != name { s.releaseImage(ctx, old) } writeJSON(w, http.StatusOK, map[string]string{"avatar": "/images/" + name}) } func (s *Server) apiDeleteAvatar(w http.ResponseWriter, r *http.Request) { ctx := r.Context() set, err := s.store.Settings(ctx) if err != nil { s.writeErr(w, err) return } if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: ""}); err != nil { s.writeErr(w, err) return } s.releaseImage(ctx, set[store.SettingAvatar]) w.WriteHeader(http.StatusNoContent) } var testQueries = map[string]string{ "movie": "Inception", "tv": "Breaking Bad", "album": "Abbey Road", "song": "Bohemian Rhapsody", "game": "Zelda", "theater": "Hamilton", "custom": "Chicago Bulls", } func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) { var body struct { Category string `json:"category"` } if err := decodeJSON(r, &body); err != nil { s.writeErr(w, err) return } q, ok := testQueries[body.Category] if !ok { s.writeErr(w, store.ValidationError{Msg: "Unknown category"}) return } p, err := s.providers.For(r.Context(), body.Category) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() results, err := p.Search(ctx, q) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": fmt.Sprintf("%s: %v", p.Label(), err)}) return } msg := fmt.Sprintf("%s is working. Searching “%s” returned %d results.", p.Label(), q, len(results)) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg}) } // ---- account ---- func (s *Server) apiUpdateAccount(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var body struct { CurrentPassword string `json:"currentPassword"` Username string `json:"username"` NewPassword string `json:"newPassword"` } if err := decodeJSON(r, &body); err != nil { s.writeErr(w, err) return } su, _ := s.currentUser(r) u, err := s.store.UserByID(ctx, su.ID) if err != nil { s.writeErr(w, err) return } if !auth.CheckPassword(u.PasswordHash, body.CurrentPassword) { writeError(w, http.StatusBadRequest, "Your current password isn't right.") return } username := strings.TrimSpace(body.Username) if username == "" || len(username) > 64 { writeError(w, http.StatusBadRequest, "Pick a username (up to 64 characters).") return } hash := u.PasswordHash if body.NewPassword != "" { if len(body.NewPassword) < auth.MinPasswordLength { writeError(w, http.StatusBadRequest, "Use a password of at least 8 characters.") return } if hash, err = auth.HashPassword(body.NewPassword); err != nil { s.writeErr(w, err) return } } if err := s.store.UpdateUser(ctx, u.ID, username, hash); err != nil { if strings.Contains(err.Error(), "UNIQUE") { writeError(w, http.StatusBadRequest, "That username is taken.") return } s.writeErr(w, err) return } if body.NewPassword != "" { // Sign out every other browser. s.store.DeleteUserSessions(ctx, u.ID, su.TokenHash) } writeJSON(w, http.StatusOK, map[string]string{"username": username}) }