Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
769 lines
20 KiB
Go
769 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/cdnutter/canon/internal/auth"
|
|
"github.com/cdnutter/canon/internal/imgcache"
|
|
"github.com/cdnutter/canon/internal/providers"
|
|
"github.com/cdnutter/canon/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"`
|
|
Description string `json:"description"`
|
|
Note string `json:"note"`
|
|
ExternalURL string `json:"externalUrl"`
|
|
Extra map[string]string `json:"extra"`
|
|
ImageURL string `json:"imageUrl"`
|
|
ClearImage bool `json:"clearImage"`
|
|
// CustomArt marks an added entry's image as the owner's own choice
|
|
// rather than the search result's.
|
|
CustomArt bool `json:"customArt"`
|
|
|
|
// 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,
|
|
Overview: in.Description,
|
|
}
|
|
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,
|
|
Description: res.Overview, 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, it.CustomArt = name, res.ImageURL, in.CustomArt
|
|
}
|
|
}
|
|
s.matchDeezer(ctx, list.Category, &it)
|
|
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 {
|
|
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
|
|
it.Description = in.Description
|
|
if in.Extra != nil {
|
|
it.Extra = in.Extra
|
|
}
|
|
// Removing art or swapping it in by hand makes it the owner's choice,
|
|
// which a refresh leaves alone unless asked.
|
|
if in.ClearImage {
|
|
it.Image, it.ImageSource, it.CustomArt = "", "", true
|
|
}
|
|
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, it.CustomArt = name, url, true
|
|
}
|
|
if it.Title != cur.Title || it.Subtitle != cur.Subtitle || it.Extra["deezer"] == "" {
|
|
if l, err := s.store.ListByID(ctx, it.ListID); err == nil {
|
|
s.matchDeezer(ctx, l.Category, &it)
|
|
}
|
|
}
|
|
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, it.CustomArt = name, "upload", true
|
|
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
|
|
}
|
|
places, err := s.store.MoveItem(r.Context(), id, body.Rank)
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"places": places})
|
|
}
|
|
|
|
// apiPlaces changes a list's empty places: insert one, remove one, or close
|
|
// every gap. It replies with where each entry now sits.
|
|
func (s *Server) apiPlaces(w http.ResponseWriter, r *http.Request) {
|
|
listID, err := pathID(r)
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
var body struct {
|
|
Action string `json:"action"`
|
|
At int `json:"at"`
|
|
Count int `json:"count"` // remove: how many empty places
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
var places []store.Place
|
|
switch body.Action {
|
|
case "insert":
|
|
places, err = s.store.InsertPlace(r.Context(), listID, body.At)
|
|
case "remove":
|
|
places, err = s.store.RemovePlace(r.Context(), listID, body.At, body.Count)
|
|
case "compact":
|
|
places, err = s.store.CloseGaps(r.Context(), listID)
|
|
default:
|
|
err = store.ValidationError{Msg: `action must be "insert", "remove" or "compact"`}
|
|
}
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"places": places})
|
|
}
|
|
|
|
func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
// ?keep=1 leaves the place empty instead of moving the entries below up.
|
|
it, err := s.store.DeleteItem(r.Context(), id, r.URL.Query().Get("keep") != "1")
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
s.releaseImage(r.Context(), it.Image)
|
|
places, err := s.store.Places(r.Context(), it.ListID)
|
|
if err != nil {
|
|
s.writeErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"places": places})
|
|
}
|
|
|
|
// ---- search ----
|
|
|
|
type searchResult struct {
|
|
providers.Result
|
|
ExistingRank int `json:"existingRank,omitempty"`
|
|
PreviewRef string `json:"previewRef,omitempty"` // Deezer id to audition before adding
|
|
}
|
|
|
|
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 res.Provider == "deezer" && hasPreviews(category) {
|
|
sr.PreviewRef = res.ProviderID
|
|
}
|
|
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],
|
|
"siteTitleShown": site.Title,
|
|
"ownerName": site.OwnerName,
|
|
"tagline": site.Tagline,
|
|
"avatar": site.AvatarURL,
|
|
"theme": site.Theme,
|
|
"customCss": set[store.SettingCustomCSS],
|
|
"previewCount": site.PreviewCount,
|
|
"showNotes": site.ShowNotes,
|
|
"showPreviews": site.ShowPreviews,
|
|
"musicProvider": orDefault(set[store.SettingMusicProvider], "both"),
|
|
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
|
|
"secrets": secrets,
|
|
"themes": Themes,
|
|
"username": u.Username,
|
|
"sitePassword": set[store.SettingSitePassword] != "",
|
|
})
|
|
}
|
|
|
|
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 != "both" && 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["showPreviews"]; ok {
|
|
b, ok := v.(bool)
|
|
if !ok {
|
|
s.writeErr(w, store.ValidationError{Msg: "showPreviews must be true or false"})
|
|
return
|
|
}
|
|
out[store.SettingShowPreviews] = strconv.FormatBool(b)
|
|
}
|
|
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})
|
|
}
|