Settings → Privacy can set a password for the whole public site. Until a visitor enters it, every public page shows a prompt over a blurred made-up page, and images, preview clips and exports return 401; nothing real is sent. Unlocking sets a 90-day cookie derived from the password hash, so changing the password asks everyone again. The owner is never asked while signed in. Wrong guesses are rate limited, and a locked site sends noindex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
727 lines
19 KiB
Go
727 lines
19 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
|
|
}
|
|
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"`
|
|
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})
|
|
}
|