decred.org/dcrdex@v1.0.5/server/admin/api.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package admin
     5  
     6  import (
     7  	"encoding/hex"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"math"
    13  	"net/http"
    14  	"strconv"
    15  	"strings"
    16  	"time"
    17  
    18  	"decred.org/dcrdex/dex"
    19  	"decred.org/dcrdex/dex/msgjson"
    20  	"decred.org/dcrdex/dex/order"
    21  	"decred.org/dcrdex/server/account"
    22  	dexsrv "decred.org/dcrdex/server/dex"
    23  	"decred.org/dcrdex/server/market"
    24  	"github.com/go-chi/chi/v5"
    25  )
    26  
    27  const (
    28  	pongStr   = "pong"
    29  	maxUInt16 = int(^uint16(0))
    30  )
    31  
    32  // writeJSON marshals the provided interface and writes the bytes to the
    33  // ResponseWriter. The response code is assumed to be StatusOK.
    34  func writeJSON(w http.ResponseWriter, thing any) {
    35  	writeJSONWithStatus(w, thing, http.StatusOK)
    36  }
    37  
    38  // writeJSON marshals the provided interface and writes the bytes to the
    39  // ResponseWriter with the specified response code.
    40  func writeJSONWithStatus(w http.ResponseWriter, thing any, code int) {
    41  	w.Header().Set("Content-Type", "application/json; charset=utf-8")
    42  	b, err := json.MarshalIndent(thing, "", "    ")
    43  	if err != nil {
    44  		w.WriteHeader(http.StatusInternalServerError)
    45  		log.Errorf("JSON encode error: %v", err)
    46  		return
    47  	}
    48  	w.WriteHeader(code)
    49  	_, err = w.Write(append(b, byte('\n')))
    50  	if err != nil {
    51  		log.Errorf("Write error: %v", err)
    52  	}
    53  }
    54  
    55  // apiPing is the handler for the '/ping' API request.
    56  func apiPing(w http.ResponseWriter, _ *http.Request) {
    57  	writeJSON(w, pongStr)
    58  }
    59  
    60  // apiConfig is the handler for the '/config' API request.
    61  func (s *Server) apiConfig(w http.ResponseWriter, _ *http.Request) {
    62  	writeJSON(w, s.core.ConfigMsg())
    63  }
    64  
    65  // apiAsset is the handler for the '/asset/{"assetSymbol"}' API request.
    66  func (s *Server) apiAsset(w http.ResponseWriter, r *http.Request) {
    67  	assetSymbol := strings.ToLower(chi.URLParam(r, assetSymbol))
    68  	assetID, found := dex.BipSymbolID(assetSymbol)
    69  	if !found {
    70  		http.Error(w, fmt.Sprintf("unknown asset %q", assetSymbol), http.StatusBadRequest)
    71  		return
    72  	}
    73  	asset, err := s.core.Asset(assetID)
    74  	if err != nil {
    75  		http.Error(w, fmt.Sprintf("unsupported asset %q / %d", assetSymbol, assetID), http.StatusBadRequest)
    76  		return
    77  	}
    78  
    79  	var errs []string
    80  	backend := asset.Backend
    81  	var scaledFeeRate uint64
    82  	currentFeeRate, err := backend.FeeRate(r.Context())
    83  	if err != nil {
    84  		errs = append(errs, fmt.Sprintf("unable to get current fee rate: %v", err))
    85  	} else {
    86  		scaledFeeRate = s.core.ScaleFeeRate(assetID, currentFeeRate)
    87  		// Limit the scaled fee rate just as in (*Market).processReadyEpoch.
    88  		if scaledFeeRate > asset.MaxFeeRate {
    89  			scaledFeeRate = asset.MaxFeeRate
    90  		}
    91  	}
    92  
    93  	synced, err := backend.Synced()
    94  	if err != nil {
    95  		errs = append(errs, fmt.Sprintf("unable to get sync status: %v", err))
    96  	}
    97  
    98  	res := &AssetInfo{
    99  		Asset:          asset.Asset,
   100  		CurrentFeeRate: currentFeeRate,
   101  		ScaledFeeRate:  scaledFeeRate,
   102  		Synced:         synced,
   103  		Errors:         errs,
   104  	}
   105  	writeJSON(w, res)
   106  }
   107  
   108  // apiSetFeeScale is the handler for the
   109  // '/asset/{"assetSymbol"}/setfeescale/{"scaleKey"}' API request.
   110  func (s *Server) apiSetFeeScale(w http.ResponseWriter, r *http.Request) {
   111  	assetSymbol := strings.ToLower(chi.URLParam(r, assetSymbol))
   112  	assetID, found := dex.BipSymbolID(assetSymbol)
   113  	if !found {
   114  		http.Error(w, fmt.Sprintf("unknown asset %q", assetSymbol), http.StatusBadRequest)
   115  		return
   116  	}
   117  
   118  	feeRateScaleStr := chi.URLParam(r, scaleKey)
   119  	feeRateScale, err := strconv.ParseFloat(feeRateScaleStr, 64)
   120  	if err != nil {
   121  		http.Error(w, fmt.Sprintf("invalid fee rate scale %q", feeRateScaleStr), http.StatusBadRequest)
   122  		return
   123  	}
   124  
   125  	_, err = s.core.Asset(assetID) // asset return may be used if other asset settings are modified
   126  	if err != nil {
   127  		http.Error(w, fmt.Sprintf("unsupported asset %q / %d", assetSymbol, assetID), http.StatusBadRequest)
   128  		return
   129  	}
   130  
   131  	log.Infof("Setting %s (%d) fee rate scale factor to %f", strings.ToUpper(assetSymbol), assetID, feeRateScale)
   132  	s.core.SetFeeRateScale(assetID, feeRateScale)
   133  
   134  	w.WriteHeader(http.StatusOK)
   135  }
   136  
   137  // apiMarkets is the handler for the '/markets' API request.
   138  func (s *Server) apiMarkets(w http.ResponseWriter, r *http.Request) {
   139  	statuses := s.core.MarketStatuses()
   140  	mktStatuses := make(map[string]*MarketStatus)
   141  	for name, status := range statuses {
   142  		mktStatus := &MarketStatus{
   143  			// Name is empty since the key is the name.
   144  			Running:       status.Running,
   145  			EpochDuration: status.EpochDuration,
   146  			ActiveEpoch:   status.ActiveEpoch,
   147  			StartEpoch:    status.StartEpoch,
   148  			SuspendEpoch:  status.SuspendEpoch,
   149  		}
   150  		if status.SuspendEpoch != 0 {
   151  			persist := status.PersistBook
   152  			mktStatus.PersistBook = &persist
   153  		}
   154  		mktStatuses[name] = mktStatus
   155  	}
   156  
   157  	writeJSON(w, mktStatuses)
   158  }
   159  
   160  // apiMarketInfo is the handler for the '/market/{marketName}' API request.
   161  func (s *Server) apiMarketInfo(w http.ResponseWriter, r *http.Request) {
   162  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   163  	status := s.core.MarketStatus(mkt)
   164  	if status == nil {
   165  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   166  		return
   167  	}
   168  
   169  	var persist *bool
   170  	if status.SuspendEpoch != 0 {
   171  		persistLocal := status.PersistBook
   172  		persist = &persistLocal
   173  	}
   174  	mktStatus := &MarketStatus{
   175  		Name:          mkt,
   176  		Running:       status.Running,
   177  		EpochDuration: status.EpochDuration,
   178  		ActiveEpoch:   status.ActiveEpoch,
   179  		StartEpoch:    status.StartEpoch,
   180  		SuspendEpoch:  status.SuspendEpoch,
   181  		PersistBook:   persist,
   182  	}
   183  	if status.SuspendEpoch != 0 {
   184  		persist := status.PersistBook
   185  		mktStatus.PersistBook = &persist
   186  	}
   187  	writeJSON(w, mktStatus)
   188  }
   189  
   190  // apiMarketOrderBook is the handler for the '/market/{marketName}/orderbook'
   191  // API request.
   192  func (s *Server) apiMarketOrderBook(w http.ResponseWriter, r *http.Request) {
   193  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   194  	status := s.core.MarketStatus(mkt)
   195  	if status == nil {
   196  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   197  		return
   198  	}
   199  	orders, err := s.core.BookOrders(status.Base, status.Quote)
   200  	if err != nil {
   201  		http.Error(w, fmt.Sprintf("failed to obtain order book: %v", err), http.StatusInternalServerError)
   202  		return
   203  	}
   204  	msgBook := make([]*msgjson.BookOrderNote, 0, len(orders))
   205  	for _, o := range orders {
   206  		msgOrder, err := market.OrderToMsgOrder(o, mkt)
   207  		if err != nil {
   208  			log.Errorf("unable to encode order: %w", err)
   209  			continue
   210  		}
   211  		msgBook = append(msgBook, msgOrder)
   212  	}
   213  	// This is a msgjson.OrderBook without the seq field.
   214  	res := &struct {
   215  		MarketID string                   `json:"marketid"`
   216  		Epoch    uint64                   `json:"epoch"`
   217  		Orders   []*msgjson.BookOrderNote `json:"orders"`
   218  	}{
   219  		MarketID: mkt,
   220  		Epoch:    uint64(status.ActiveEpoch),
   221  		Orders:   msgBook,
   222  	}
   223  	writeJSON(w, res)
   224  }
   225  
   226  // handler for route '/market/{marketName}/epochorders' API request.
   227  func (s *Server) apiMarketEpochOrders(w http.ResponseWriter, r *http.Request) {
   228  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   229  	status := s.core.MarketStatus(mkt)
   230  	if status == nil {
   231  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   232  		return
   233  	}
   234  	orders, err := s.core.EpochOrders(status.Base, status.Quote)
   235  	if err != nil {
   236  		http.Error(w, fmt.Sprintf("failed to obtain epoch orders: %v", err), http.StatusInternalServerError)
   237  		return
   238  	}
   239  	msgBook := make([]*msgjson.BookOrderNote, 0, len(orders))
   240  	for _, o := range orders {
   241  		msgOrder, err := market.OrderToMsgOrder(o, mkt)
   242  		if err != nil {
   243  			log.Errorf("unable to encode order: %w", err)
   244  			continue
   245  		}
   246  		msgBook = append(msgBook, msgOrder)
   247  	}
   248  	// This is a msgjson.OrderBook without the seq field.
   249  	res := &struct {
   250  		MarketID string                   `json:"marketid"`
   251  		Epoch    uint64                   `json:"epoch"`
   252  		Orders   []*msgjson.BookOrderNote `json:"orders"`
   253  	}{
   254  		MarketID: mkt,
   255  		Epoch:    uint64(status.ActiveEpoch),
   256  		Orders:   msgBook,
   257  	}
   258  	writeJSON(w, res)
   259  }
   260  
   261  // handler for route '/market/{marketName}/matches?includeinactive=BOOL&n=INT' API
   262  // request. The n value is only used when includeinactive is true.
   263  func (s *Server) apiMarketMatches(w http.ResponseWriter, r *http.Request) {
   264  	var includeInactive bool
   265  	if includeInactiveStr := r.URL.Query().Get(includeInactiveKey); includeInactiveStr != "" {
   266  		var err error
   267  		includeInactive, err = strconv.ParseBool(includeInactiveStr)
   268  		if err != nil {
   269  			http.Error(w, fmt.Sprintf("invalid include inactive boolean %q: %v", includeInactiveStr, err), http.StatusBadRequest)
   270  			return
   271  		}
   272  	}
   273  	var N int64 // <=0 is all
   274  	if nStr := r.URL.Query().Get(nKey); nStr != "" {
   275  		var err error
   276  		N, err = strconv.ParseInt(nStr, 10, 64)
   277  		if err != nil {
   278  			http.Error(w, fmt.Sprintf("invalid n int %q: %v", nStr, err), http.StatusBadRequest)
   279  			return
   280  		}
   281  	}
   282  
   283  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   284  	status := s.core.MarketStatus(mkt)
   285  	if status == nil {
   286  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   287  		return
   288  	}
   289  
   290  	w.Header().Set("Content-Type", "application/json; charset=utf-8")
   291  
   292  	// The response is not an array, but a series of objects. TIP: Use "jq -s"
   293  	// to generate an array of these objects.
   294  	enc := json.NewEncoder(w)
   295  	enc.SetIndent("", "    ")
   296  	Nout, err := s.core.MarketMatchesStreaming(status.Base, status.Quote, includeInactive, N,
   297  		func(match *dexsrv.MatchData) error {
   298  			md := &MatchData{
   299  				TakerSell:   match.TakerSell,
   300  				ID:          match.ID.String(),
   301  				Maker:       match.Maker.String(),
   302  				MakerAcct:   match.MakerAcct.String(),
   303  				MakerSwap:   match.MakerSwap,
   304  				MakerRedeem: match.MakerRedeem,
   305  				MakerAddr:   match.MakerAddr,
   306  				Taker:       match.Taker.String(),
   307  				TakerAcct:   match.TakerAcct.String(),
   308  				TakerSwap:   match.TakerSwap,
   309  				TakerRedeem: match.TakerRedeem,
   310  				TakerAddr:   match.TakerAddr,
   311  				EpochIdx:    match.Epoch.Idx,
   312  				EpochDur:    match.Epoch.Dur,
   313  				Quantity:    match.Quantity,
   314  				Rate:        match.Rate,
   315  				BaseRate:    match.BaseRate,
   316  				QuoteRate:   match.QuoteRate,
   317  				Active:      match.Active,
   318  				Status:      match.Status.String(),
   319  			}
   320  			return enc.Encode(md)
   321  		})
   322  	if err != nil {
   323  		log.Warnf("Failed to write matches response: %v", err)
   324  		if Nout == 0 {
   325  			http.Error(w, fmt.Sprintf("failed to retrieve matches: %v", err), http.StatusInternalServerError)
   326  		} // otherwise too late for an http error code
   327  	}
   328  }
   329  
   330  // handler for route '/market/{marketName}/resume?t=UNIXMS'
   331  func (s *Server) apiResume(w http.ResponseWriter, r *http.Request) {
   332  	// Ensure the market exists and is not running.
   333  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   334  	found, running := s.core.MarketRunning(mkt)
   335  	if !found {
   336  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   337  		return
   338  	}
   339  	if running {
   340  		http.Error(w, fmt.Sprintf("market %q running", mkt), http.StatusBadRequest)
   341  		return
   342  	}
   343  
   344  	// Validate the resume time provided in the "t" query. If not specified,
   345  	// the zero time.Time is used to indicate ASAP.
   346  	var resTime time.Time
   347  	if tResumeStr := r.URL.Query().Get("t"); tResumeStr != "" {
   348  		resTimeMs, err := strconv.ParseInt(tResumeStr, 10, 64)
   349  		if err != nil {
   350  			http.Error(w, fmt.Sprintf("invalid resume time %q: %v", tResumeStr, err), http.StatusBadRequest)
   351  			return
   352  		}
   353  
   354  		resTime = time.UnixMilli(resTimeMs)
   355  		if time.Until(resTime) < 0 {
   356  			http.Error(w, fmt.Sprintf("specified market resume time is in the past: %v", resTime),
   357  				http.StatusBadRequest)
   358  			return
   359  		}
   360  	}
   361  
   362  	resEpoch, resTime, err := s.core.ResumeMarket(mkt, resTime)
   363  	if resEpoch == 0 || err != nil {
   364  		// Should not happen.
   365  		msg := fmt.Sprintf("Failed to resume market: %v", err)
   366  		log.Errorf(msg)
   367  		http.Error(w, msg, http.StatusInternalServerError)
   368  		return
   369  	}
   370  
   371  	writeJSON(w, &ResumeResult{
   372  		Market:     mkt,
   373  		StartEpoch: resEpoch,
   374  		StartTime:  APITime{resTime},
   375  	})
   376  }
   377  
   378  // handler for route '/market/{marketName}/suspend?t=UNIXMS&persist=BOOL'
   379  func (s *Server) apiSuspend(w http.ResponseWriter, r *http.Request) {
   380  	// Ensure the market exists and is running.
   381  	mkt := strings.ToLower(chi.URLParam(r, marketNameKey))
   382  	found, running := s.core.MarketRunning(mkt)
   383  	if !found {
   384  		http.Error(w, fmt.Sprintf("unknown market %q", mkt), http.StatusBadRequest)
   385  		return
   386  	}
   387  	if !running {
   388  		http.Error(w, fmt.Sprintf("market %q not running", mkt), http.StatusBadRequest)
   389  		return
   390  	}
   391  
   392  	// Validate the suspend time provided in the "t" query. If not specified,
   393  	// the zero time.Time is used to indicate ASAP.
   394  	var suspTime time.Time
   395  	if tSuspendStr := r.URL.Query().Get("t"); tSuspendStr != "" {
   396  		suspTimeMs, err := strconv.ParseInt(tSuspendStr, 10, 64)
   397  		if err != nil {
   398  			http.Error(w, fmt.Sprintf("invalid suspend time %q: %v", tSuspendStr, err), http.StatusBadRequest)
   399  			return
   400  		}
   401  
   402  		suspTime = time.UnixMilli(suspTimeMs)
   403  		if time.Until(suspTime) < 0 {
   404  			http.Error(w, fmt.Sprintf("specified market suspend time is in the past: %v", suspTime),
   405  				http.StatusBadRequest)
   406  			return
   407  		}
   408  	}
   409  
   410  	// Validate the persist book flag provided in the "persist" query. If not
   411  	// specified, persist the books, do not purge.
   412  	persistBook := true
   413  	if persistBookStr := r.URL.Query().Get("persist"); persistBookStr != "" {
   414  		var err error
   415  		persistBook, err = strconv.ParseBool(persistBookStr)
   416  		if err != nil {
   417  			http.Error(w, fmt.Sprintf("invalid persist book boolean %q: %v", persistBookStr, err), http.StatusBadRequest)
   418  			return
   419  		}
   420  	}
   421  
   422  	suspEpoch, err := s.core.SuspendMarket(mkt, suspTime, persistBook)
   423  	if suspEpoch == nil || err != nil {
   424  		// Should not happen.
   425  		msg := fmt.Sprintf("Failed to suspend market: %v", err)
   426  		log.Errorf(msg)
   427  		http.Error(w, msg, http.StatusInternalServerError)
   428  		return
   429  	}
   430  
   431  	writeJSON(w, &SuspendResult{
   432  		Market:      mkt,
   433  		FinalEpoch:  suspEpoch.Idx,
   434  		SuspendTime: APITime{suspEpoch.End},
   435  	})
   436  }
   437  
   438  // apiEnableDataAPI is the handler for the `/enabledataapi/{yes}` API request,
   439  // used to enable or disable the HTTP data API.
   440  func (s *Server) apiEnableDataAPI(w http.ResponseWriter, r *http.Request) {
   441  	yes, err := strconv.ParseBool(chi.URLParam(r, yesKey))
   442  	if err != nil {
   443  		http.Error(w, "unable to parse selection: "+err.Error(), http.StatusBadRequest)
   444  		return
   445  	}
   446  	s.core.EnableDataAPI(yes)
   447  	msg := "Data API disabled"
   448  	if yes {
   449  		msg = "Data API enabled"
   450  	}
   451  	writeJSON(w, msg)
   452  }
   453  
   454  // apiAccountInfo is the handler for the '/account/{account id}' API request.
   455  func (s *Server) apiAccountInfo(w http.ResponseWriter, r *http.Request) {
   456  	acctIDStr := chi.URLParam(r, accountIDKey)
   457  	acctIDSlice, err := hex.DecodeString(acctIDStr)
   458  	if err != nil {
   459  		http.Error(w, fmt.Sprintf("could not decode account id: %v", err), http.StatusBadRequest)
   460  		return
   461  	}
   462  	if len(acctIDSlice) != account.HashSize {
   463  		http.Error(w, "account id has incorrect length", http.StatusBadRequest)
   464  		return
   465  	}
   466  	var acctID account.AccountID
   467  	copy(acctID[:], acctIDSlice)
   468  	acctInfo, err := s.core.AccountInfo(acctID)
   469  	if err != nil {
   470  		http.Error(w, fmt.Sprintf("failed to retrieve account: %v", err), http.StatusInternalServerError)
   471  		return
   472  	}
   473  	writeJSON(w, acctInfo)
   474  }
   475  
   476  func (s *Server) prepayBonds(w http.ResponseWriter, r *http.Request) {
   477  	var n int = 1
   478  	if nStr := r.URL.Query().Get(nKey); nStr != "" {
   479  		n64, err := strconv.ParseUint(nStr, 10, 16)
   480  		if err != nil {
   481  			http.Error(w, fmt.Sprintf("error parsing n: %v", err), http.StatusBadRequest)
   482  			return
   483  		}
   484  		n = int(n64)
   485  	}
   486  	if n < 0 || n > 100 {
   487  		http.Error(w, "requested too many prepaid bonds. max 100", http.StatusBadRequest)
   488  		return
   489  	}
   490  	daysStr := r.URL.Query().Get(daysKey)
   491  	if daysStr == "" {
   492  		http.Error(w, "no days duration specified", http.StatusBadRequest)
   493  		return
   494  	}
   495  	days, err := strconv.ParseUint(daysStr, 10, 64)
   496  	if err != nil {
   497  		http.Error(w, fmt.Sprintf("error parsing days: %v", err), http.StatusBadRequest)
   498  		return
   499  	}
   500  	if days == 0 {
   501  		http.Error(w, "days parsed to zero", http.StatusBadRequest)
   502  		return
   503  	}
   504  	dur := time.Duration(days) * time.Hour * 24
   505  	var strength uint32 = 1
   506  	if strengthStr := r.URL.Query().Get(strengthKey); strengthStr != "" {
   507  		n64, err := strconv.ParseUint(strengthStr, 10, 32)
   508  		if err != nil {
   509  			http.Error(w, fmt.Sprintf("error parsing strength: %v", err), http.StatusBadRequest)
   510  			return
   511  		}
   512  		strength = uint32(n64)
   513  	}
   514  	coinIDs, err := s.core.CreatePrepaidBonds(n, strength, int64(math.Round(dur.Seconds())))
   515  	if err != nil {
   516  		http.Error(w, fmt.Sprintf("error creating bonds: %v", err), http.StatusInternalServerError)
   517  		return
   518  	}
   519  	res := make([]dex.Bytes, len(coinIDs))
   520  	for i := range coinIDs {
   521  		res[i] = coinIDs[i]
   522  	}
   523  	writeJSON(w, res)
   524  }
   525  
   526  // decodeAcctID checks a string as being both hex and the right length and
   527  // returns its bytes encoded as an account.AccountID.
   528  func decodeAcctID(acctIDStr string) (account.AccountID, error) {
   529  	var acctID account.AccountID
   530  	if len(acctIDStr) != account.HashSize*2 {
   531  		return acctID, errors.New("account id has incorrect length")
   532  	}
   533  	if _, err := hex.Decode(acctID[:], []byte(acctIDStr)); err != nil {
   534  		return acctID, fmt.Errorf("could not decode account id: %w", err)
   535  	}
   536  	return acctID, nil
   537  }
   538  
   539  // apiForgiveMatchFail is the handler for the '/account/{accountID}/forgive_match/{matchID}' API request.
   540  func (s *Server) apiForgiveMatchFail(w http.ResponseWriter, r *http.Request) {
   541  	acctIDStr := chi.URLParam(r, accountIDKey)
   542  	acctID, err := decodeAcctID(acctIDStr)
   543  	if err != nil {
   544  		http.Error(w, err.Error(), http.StatusBadRequest)
   545  		return
   546  	}
   547  	matchIDStr := chi.URLParam(r, matchIDKey)
   548  	matchID, err := order.DecodeMatchID(matchIDStr)
   549  	if err != nil {
   550  		http.Error(w, err.Error(), http.StatusBadRequest)
   551  		return
   552  	}
   553  	forgiven, unbanned, err := s.core.ForgiveMatchFail(acctID, matchID)
   554  	if err != nil {
   555  		http.Error(w, fmt.Sprintf("failed to forgive failed match %v for account %v: %v", matchID, acctID, err), http.StatusInternalServerError)
   556  		return
   557  	}
   558  	res := ForgiveResult{
   559  		AccountID:   acctIDStr,
   560  		Forgiven:    forgiven,
   561  		Unbanned:    unbanned,
   562  		ForgiveTime: APITime{time.Now()},
   563  	}
   564  	writeJSON(w, res)
   565  }
   566  
   567  func (s *Server) apiMatchOutcomes(w http.ResponseWriter, r *http.Request) {
   568  	acctIDStr := chi.URLParam(r, accountIDKey)
   569  	acctID, err := decodeAcctID(acctIDStr)
   570  	if err != nil {
   571  		http.Error(w, err.Error(), http.StatusBadRequest)
   572  		return
   573  	}
   574  	var n int = 100
   575  	if nStr := r.URL.Query().Get("n"); nStr != "" {
   576  		n, err = strconv.Atoi(nStr)
   577  		if err != nil {
   578  			http.Error(w, err.Error(), http.StatusBadRequest)
   579  			return
   580  		}
   581  	}
   582  	outcomes, err := s.core.AccountMatchOutcomesN(acctID, n)
   583  	if err != nil {
   584  		http.Error(w, err.Error(), http.StatusInternalServerError)
   585  		return
   586  	}
   587  	writeJSON(w, outcomes)
   588  }
   589  
   590  func (s *Server) apiMatchFails(w http.ResponseWriter, r *http.Request) {
   591  	acctIDStr := chi.URLParam(r, accountIDKey)
   592  	acctID, err := decodeAcctID(acctIDStr)
   593  	if err != nil {
   594  		http.Error(w, err.Error(), http.StatusBadRequest)
   595  		return
   596  	}
   597  	var n int = 100
   598  	if nStr := r.URL.Query().Get("n"); nStr != "" {
   599  		n, err = strconv.Atoi(nStr)
   600  		if err != nil {
   601  			http.Error(w, err.Error(), http.StatusBadRequest)
   602  			return
   603  		}
   604  	}
   605  	fails, err := s.core.UserMatchFails(acctID, n)
   606  	if err != nil {
   607  		http.Error(w, err.Error(), http.StatusInternalServerError)
   608  		return
   609  	}
   610  	writeJSON(w, fails)
   611  }
   612  
   613  func toNote(r *http.Request) (*msgjson.Message, int, error) {
   614  	body, err := io.ReadAll(r.Body)
   615  	r.Body.Close()
   616  	if err != nil {
   617  		return nil, http.StatusInternalServerError, fmt.Errorf("unable to read request body: %w", err)
   618  	}
   619  	if len(body) == 0 {
   620  		return nil, http.StatusBadRequest, errors.New("no message to broadcast")
   621  	}
   622  	// Remove trailing newline if present. A newline is added by the curl
   623  	// command when sending from file.
   624  	if body[len(body)-1] == '\n' {
   625  		body = body[:len(body)-1]
   626  	}
   627  	if len(body) > maxUInt16 {
   628  		return nil, http.StatusBadRequest, fmt.Errorf("cannot send messages larger than %d bytes", maxUInt16)
   629  	}
   630  	msg, err := msgjson.NewNotification(msgjson.NotifyRoute, string(body))
   631  	if err != nil {
   632  		return nil, http.StatusInternalServerError, fmt.Errorf("unable to create notification: %w", err)
   633  	}
   634  	return msg, 0, nil
   635  }
   636  
   637  // apiNotify is the handler for the '/account/{accountID}/notify' API request.
   638  func (s *Server) apiNotify(w http.ResponseWriter, r *http.Request) {
   639  	acctIDStr := chi.URLParam(r, accountIDKey)
   640  	acctID, err := decodeAcctID(acctIDStr)
   641  	if err != nil {
   642  		http.Error(w, err.Error(), http.StatusBadRequest)
   643  		return
   644  	}
   645  	msg, errCode, err := toNote(r)
   646  	if err != nil {
   647  		http.Error(w, err.Error(), errCode)
   648  		return
   649  	}
   650  	s.core.Notify(acctID, msg)
   651  	w.WriteHeader(http.StatusOK)
   652  }
   653  
   654  // apiNotifyAll is the handler for the '/notifyall' API request.
   655  func (s *Server) apiNotifyAll(w http.ResponseWriter, r *http.Request) {
   656  	msg, errCode, err := toNote(r)
   657  	if err != nil {
   658  		http.Error(w, err.Error(), errCode)
   659  		return
   660  	}
   661  	s.core.NotifyAll(msg)
   662  	w.WriteHeader(http.StatusOK)
   663  }