decred.org/dcrdex@v1.0.5/client/webserver/http.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 webserver
     5  
     6  import (
     7  	"encoding/csv"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"os"
    12  	"sort"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	"decred.org/dcrdex/client/asset"
    18  	"decred.org/dcrdex/client/core"
    19  	"decred.org/dcrdex/dex"
    20  	"decred.org/dcrdex/dex/order"
    21  	qrcode "github.com/skip2/go-qrcode"
    22  )
    23  
    24  const (
    25  	homeRoute        = "/"
    26  	registerRoute    = "/register"
    27  	initRoute        = "/init"
    28  	loginRoute       = "/login"
    29  	marketsRoute     = "/markets"
    30  	walletsRoute     = "/wallets"
    31  	walletLogRoute   = "/wallets/logfile"
    32  	settingsRoute    = "/settings"
    33  	ordersRoute      = "/orders"
    34  	exportOrderRoute = "/orders/export"
    35  	marketMakerRoute = "/mm"
    36  	mmSettingsRoute  = "/mmsettings"
    37  	mmArchivesRoute  = "/mmarchives"
    38  	mmLogsRoute      = "/mmlogs"
    39  )
    40  
    41  // sendTemplate processes the template and sends the result.
    42  func (s *WebServer) sendTemplate(w http.ResponseWriter, tmplID string, data any) {
    43  	html := s.html.Load().(*templates)
    44  	page, err := html.exec(tmplID, data)
    45  	if err != nil {
    46  		log.Errorf("template exec error for %s: %v", tmplID, err)
    47  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    48  		return
    49  	}
    50  	w.Header().Set("Content-Type", "text/html;charset=UTF-8")
    51  	w.WriteHeader(http.StatusOK)
    52  	io.WriteString(w, page)
    53  }
    54  
    55  // CommonArguments are common page arguments that must be supplied to every
    56  // page to populate the <title> and <header> elements.
    57  type CommonArguments struct {
    58  	UserInfo               *userInfo
    59  	Title                  string
    60  	UseDEXBranding         bool
    61  	Version                string
    62  	NewAppVersionAvailable bool
    63  }
    64  
    65  // Create the CommonArguments for the request.
    66  func (s *WebServer) commonArgs(r *http.Request, title string) *CommonArguments {
    67  	return &CommonArguments{
    68  		UserInfo:               extractUserInfo(r),
    69  		Title:                  title,
    70  		UseDEXBranding:         s.useDEXBranding,
    71  		Version:                s.appVersion,
    72  		NewAppVersionAvailable: s.newAppVersionAvailable,
    73  	}
    74  }
    75  
    76  // handleHome is the handler for the '/' page request. It redirects the
    77  // requester to the wallets page.
    78  func (s *WebServer) handleHome(w http.ResponseWriter, r *http.Request) {
    79  	http.Redirect(w, r, walletsRoute, http.StatusSeeOther)
    80  }
    81  
    82  // handleLogin is the handler for the '/login' page request.
    83  func (s *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
    84  	cArgs := s.commonArgs(r, "Login | Bison Wallet")
    85  	if cArgs.UserInfo.Authed {
    86  		http.Redirect(w, r, walletsRoute, http.StatusSeeOther)
    87  		return
    88  	}
    89  	s.sendTemplate(w, "login", cArgs)
    90  }
    91  
    92  // registerTmplData is template data for the /register page.
    93  type registerTmplData struct {
    94  	CommonArguments
    95  	KnownExchanges []string
    96  	// Host is optional. If provided, the register page will not display the add
    97  	// dex form, instead this host will be pre-selected for registration.
    98  	Host        string
    99  	Initialized bool
   100  }
   101  
   102  // handleRegister is the handler for the '/register' page request.
   103  func (s *WebServer) handleRegister(w http.ResponseWriter, r *http.Request) {
   104  	common := s.commonArgs(r, "Register | Bison Wallet")
   105  	host, _ := getHostCtx(r)
   106  	s.sendTemplate(w, "register", &registerTmplData{
   107  		CommonArguments: *common,
   108  		Host:            host,
   109  		KnownExchanges:  s.knownUnregisteredExchanges(s.core.Exchanges()),
   110  		Initialized:     s.core.IsInitialized(),
   111  	})
   112  }
   113  
   114  // knownUnregisteredExchanges returns all the known exchanges that
   115  // the user has not registered for.
   116  func (s *WebServer) knownUnregisteredExchanges(registeredExchanges map[string]*core.Exchange) []string {
   117  	certs := core.CertStore[s.core.Network()]
   118  	exchanges := make([]string, 0, len(certs))
   119  	for host := range certs {
   120  		xc := registeredExchanges[host]
   121  		if xc == nil || xc.Auth.TargetTier == 0 {
   122  			exchanges = append(exchanges, host)
   123  		}
   124  	}
   125  	for host, xc := range registeredExchanges {
   126  		if certs[host] != nil || xc.Auth.TargetTier > 0 {
   127  			continue
   128  		}
   129  		exchanges = append(exchanges, host)
   130  	}
   131  	return exchanges
   132  }
   133  
   134  // handleMarkets is the handler for the '/markets' page request.
   135  func (s *WebServer) handleMarkets(w http.ResponseWriter, r *http.Request) {
   136  	s.sendTemplate(w, "markets", s.commonArgs(r, "Markets | Bison Wallet"))
   137  }
   138  
   139  // handleMarketMaking is the handler for the '/mm' page request.
   140  func (s *WebServer) handleMarketMaking(w http.ResponseWriter, r *http.Request) {
   141  	s.sendTemplate(w, "mm", s.commonArgs(r, "Market Making | Bison Wallet"))
   142  }
   143  
   144  // handleWallets is the handler for the '/wallets' page request.
   145  func (s *WebServer) handleWallets(w http.ResponseWriter, r *http.Request) {
   146  	assetMap := s.core.SupportedAssets()
   147  	// Sort assets by 1. wallet vs no wallet, and 2) alphabetically.
   148  	assets := make([]*core.SupportedAsset, 0, len(assetMap))
   149  	// over-allocating, but assuming user will not have set up most wallets.
   150  	nowallets := make([]*core.SupportedAsset, 0, len(assetMap))
   151  	for _, asset := range assetMap {
   152  		if asset.Wallet == nil {
   153  			nowallets = append(nowallets, asset)
   154  		} else {
   155  			assets = append(assets, asset)
   156  		}
   157  	}
   158  
   159  	sort.Slice(assets, func(i, j int) bool {
   160  		return assets[i].Name < assets[j].Name
   161  	})
   162  	sort.Slice(nowallets, func(i, j int) bool {
   163  		return nowallets[i].Name < nowallets[j].Name
   164  	})
   165  	s.sendTemplate(w, "wallets", s.commonArgs(r, "Wallets | Bison Wallet"))
   166  }
   167  
   168  // handleWalletLogFile is the handler for the '/wallets/logfile' page request.
   169  func (s *WebServer) handleWalletLogFile(w http.ResponseWriter, r *http.Request) {
   170  	err := r.ParseForm()
   171  	if err != nil {
   172  		log.Errorf("error parsing form for wallet log file: %v", err)
   173  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   174  		return
   175  	}
   176  
   177  	assetIDQueryString := r.Form["assetid"]
   178  	if len(assetIDQueryString) != 1 || len(assetIDQueryString[0]) == 0 {
   179  		log.Error("could not find asset id in query string")
   180  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   181  		return
   182  	}
   183  
   184  	assetID, err := strconv.ParseUint(assetIDQueryString[0], 10, 32)
   185  	if err != nil {
   186  		log.Errorf("failed to parse asset id query string %v", err)
   187  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   188  		return
   189  	}
   190  
   191  	logFilePath, err := s.core.WalletLogFilePath(uint32(assetID))
   192  	if err != nil {
   193  		log.Errorf("failed to get log file path %v", err)
   194  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   195  		return
   196  	}
   197  
   198  	logFile, err := os.Open(logFilePath)
   199  	if err != nil {
   200  		log.Errorf("error opening log file: %v", err)
   201  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   202  		return
   203  	}
   204  	defer logFile.Close()
   205  
   206  	assetName := dex.BipIDSymbol(uint32(assetID))
   207  	logFileName := fmt.Sprintf("dcrdex-%s-wallet.log", assetName)
   208  	w.Header().Set("Content-Disposition", "attachment; filename="+logFileName)
   209  	w.Header().Set("Content-Type", "text/plain")
   210  	w.WriteHeader(http.StatusOK)
   211  
   212  	_, err = io.Copy(w, logFile)
   213  	if err != nil {
   214  		log.Errorf("error copying log file: %v", err)
   215  	}
   216  }
   217  
   218  // handleGenerateQRCode is the handler for the '/generateqrcode' page request
   219  func (s *WebServer) handleGenerateQRCode(w http.ResponseWriter, r *http.Request) {
   220  	err := r.ParseForm()
   221  	if err != nil {
   222  		log.Errorf("error parsing form for generate qr code: %v", err)
   223  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   224  		return
   225  	}
   226  
   227  	address := r.Form["address"]
   228  	if len(address) != 1 || len(address[0]) == 0 {
   229  		log.Error("form for generating qr code does not contain address")
   230  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   231  		return
   232  	}
   233  
   234  	png, err := qrcode.Encode(address[0], qrcode.Medium, 200)
   235  	if err != nil {
   236  		log.Error("error generating qr code: %v", err)
   237  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   238  		return
   239  	}
   240  
   241  	w.Header().Set("Content-Type", "image/png")
   242  	w.Header().Set("Content-Length", strconv.Itoa(len(png)))
   243  	w.WriteHeader(http.StatusOK)
   244  
   245  	_, err = w.Write(png)
   246  	if err != nil {
   247  		log.Errorf("error writing qr code image: %v", err)
   248  	}
   249  }
   250  
   251  // handleInit is the handler for the '/init' page request
   252  func (s *WebServer) handleInit(w http.ResponseWriter, r *http.Request) {
   253  	s.sendTemplate(w, "init", s.commonArgs(r, "Welcome | Bison Wallet"))
   254  }
   255  
   256  // handleSettings is the handler for the '/settings' page request.
   257  func (s *WebServer) handleSettings(w http.ResponseWriter, r *http.Request) {
   258  	common := s.commonArgs(r, "Settings | Bison Wallet")
   259  	xcs := s.core.Exchanges()
   260  	data := &struct {
   261  		CommonArguments
   262  		KnownExchanges  []string
   263  		FiatRateSources map[string]bool
   264  		FiatCurrency    string
   265  		Exchanges       map[string]*core.Exchange
   266  		IsInitialized   bool
   267  	}{
   268  		CommonArguments: *common,
   269  		KnownExchanges:  s.knownUnregisteredExchanges(xcs),
   270  		FiatCurrency:    core.DefaultFiatCurrency,
   271  		FiatRateSources: s.core.FiatRateSources(),
   272  		Exchanges:       xcs,
   273  		IsInitialized:   s.core.IsInitialized(),
   274  	}
   275  	s.sendTemplate(w, "settings", data)
   276  }
   277  
   278  // handleDexSettings is the handler for the '/dexsettings' page request.
   279  func (s *WebServer) handleDexSettings(w http.ResponseWriter, r *http.Request) {
   280  	host, err := getHostCtx(r)
   281  	if err != nil {
   282  		log.Errorf("error getting host ctx: %v", err)
   283  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   284  		return
   285  	}
   286  
   287  	exchange, err := s.core.Exchange(host)
   288  	if err != nil {
   289  		log.Errorf("error getting exchange: %v", err)
   290  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   291  		return
   292  	}
   293  
   294  	common := *s.commonArgs(r, fmt.Sprintf("%v Settings | Bison Wallet", host))
   295  	data := &struct {
   296  		CommonArguments
   297  		Exchange       *core.Exchange
   298  		KnownExchanges []string
   299  	}{
   300  		CommonArguments: common,
   301  		Exchange:        exchange,
   302  		KnownExchanges:  s.knownUnregisteredExchanges(s.core.Exchanges()),
   303  	}
   304  
   305  	s.sendTemplate(w, "dexsettings", data)
   306  }
   307  
   308  // handleMMSettings is the handler for the '/mmsettings' page request.
   309  func (s *WebServer) handleMMSettings(w http.ResponseWriter, r *http.Request) {
   310  	common := *s.commonArgs(r, "Market Making Settings | Bison Wallet")
   311  	s.sendTemplate(w, "mmsettings", common)
   312  }
   313  
   314  // handleMMArchives is the handler for the '/mmarchives' page request.
   315  func (s *WebServer) handleMMArchives(w http.ResponseWriter, r *http.Request) {
   316  	common := *s.commonArgs(r, "Market Making Archives | Bison Wallet")
   317  	s.sendTemplate(w, "mmarchives", common)
   318  }
   319  
   320  // handleMMLogs is the handler for the '/mmlogs' page request.
   321  func (s *WebServer) handleMMLogs(w http.ResponseWriter, r *http.Request) {
   322  	common := *s.commonArgs(r, "Market Making Logs | Bison Wallet")
   323  	s.sendTemplate(w, "mmlogs", common)
   324  }
   325  
   326  type ordersTmplData struct {
   327  	CommonArguments
   328  	Assets   map[uint32]*core.SupportedAsset
   329  	Hosts    []string
   330  	Statuses map[uint8]string
   331  }
   332  
   333  var allStatuses = map[uint8]string{
   334  	uint8(order.OrderStatusEpoch):    order.OrderStatusEpoch.String(),
   335  	uint8(order.OrderStatusBooked):   order.OrderStatusBooked.String(),
   336  	uint8(order.OrderStatusExecuted): order.OrderStatusExecuted.String(),
   337  	uint8(order.OrderStatusCanceled): order.OrderStatusCanceled.String(),
   338  	uint8(order.OrderStatusRevoked):  order.OrderStatusRevoked.String(),
   339  }
   340  
   341  // handleOrders is the handler for the /orders page request.
   342  func (s *WebServer) handleOrders(w http.ResponseWriter, r *http.Request) {
   343  	xcs := s.core.Exchanges()
   344  	hosts := make([]string, 0, len(xcs))
   345  	for _, xc := range xcs {
   346  		hosts = append(hosts, xc.Host)
   347  	}
   348  
   349  	s.sendTemplate(w, "orders", &ordersTmplData{
   350  		CommonArguments: *s.commonArgs(r, "Orders | Bison Wallet"),
   351  		Assets:          s.core.SupportedAssets(),
   352  		Hosts:           hosts,
   353  		Statuses:        allStatuses,
   354  	})
   355  }
   356  
   357  // handleExportOrders is the handler for the /orders/export page request.
   358  func (s *WebServer) handleExportOrders(w http.ResponseWriter, r *http.Request) {
   359  	filter := new(core.OrderFilter)
   360  	err := r.ParseForm()
   361  	if err != nil {
   362  		log.Errorf("error parsing form for export order: %v", err)
   363  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   364  		return
   365  	}
   366  
   367  	filter.Hosts = r.Form["hosts"]
   368  	assets := r.Form["assets"]
   369  	filter.Assets = make([]uint32, len(assets))
   370  	for k, assetStrID := range assets {
   371  		assetNumID, err := strconv.ParseUint(assetStrID, 10, 32)
   372  		if err != nil {
   373  			log.Errorf("error parsing asset id: %v", err)
   374  			http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   375  			return
   376  		}
   377  		filter.Assets[k] = uint32(assetNumID)
   378  	}
   379  	statuses := r.Form["statuses"]
   380  	filter.Statuses = make([]order.OrderStatus, len(statuses))
   381  	for k, statusStrID := range statuses {
   382  		statusNumID, err := strconv.ParseUint(statusStrID, 10, 16)
   383  		if err != nil {
   384  			http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   385  			log.Errorf("error parsing status id: %v", err)
   386  			return
   387  		}
   388  		filter.Statuses[k] = order.OrderStatus(statusNumID)
   389  	}
   390  
   391  	ords, err := s.core.Orders(filter)
   392  	if err != nil {
   393  		log.Errorf("error retrieving order: %v", err)
   394  		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
   395  		return
   396  	}
   397  
   398  	w.Header().Set("Content-Disposition", "attachment; filename=orders.csv")
   399  	w.Header().Set("Content-Type", "text/csv")
   400  	w.WriteHeader(http.StatusOK)
   401  	csvWriter := csv.NewWriter(w)
   402  	csvWriter.UseCRLF = strings.Contains(r.UserAgent(), "Windows")
   403  
   404  	err = csvWriter.Write([]string{
   405  		"Host",
   406  		"Base",
   407  		"Quote",
   408  		"Base Quantity",
   409  		"Order Rate",
   410  		"Actual Rate",
   411  		"Base Fees",
   412  		"Base Fees Asset",
   413  		"Quote Fees",
   414  		"Quote Fees Asset",
   415  		"Type",
   416  		"Side",
   417  		"Time in Force",
   418  		"Status",
   419  		"Filled (%)",
   420  		"Settled (%)",
   421  		"Time",
   422  	})
   423  	if err != nil {
   424  		log.Errorf("error writing CSV: %v", err)
   425  		return
   426  	}
   427  	csvWriter.Flush()
   428  	err = csvWriter.Error()
   429  	if err != nil {
   430  		log.Errorf("error writing CSV: %v", err)
   431  		return
   432  	}
   433  
   434  	for _, ord := range ords {
   435  		ordReader := s.orderReader(ord)
   436  
   437  		timestamp := time.UnixMilli(int64(ord.Stamp)).Local().Format(time.RFC3339Nano)
   438  		err = csvWriter.Write([]string{
   439  			ord.Host,                      // Host
   440  			ord.BaseSymbol,                // Base
   441  			ord.QuoteSymbol,               // Quote
   442  			ordReader.BaseQtyString(),     // Base Quantity
   443  			ordReader.SimpleRateString(),  // Order Rate
   444  			ordReader.AverageRateString(), // Actual Rate
   445  			ordReader.BaseAssetFees(),     // Base Fees
   446  			ordReader.BaseFeeSymbol(),     // Base Fees Asset
   447  			ordReader.QuoteAssetFees(),    // Quote Fees
   448  			ordReader.QuoteFeeSymbol(),    // Quote Fees Asset
   449  			ordReader.Type.String(),       // Type
   450  			ordReader.SideString(),        // Side
   451  			ord.TimeInForce.String(),      // Time in Force
   452  			ordReader.StatusString(),      // Status
   453  			ordReader.FilledPercent(),     // Filled
   454  			ordReader.SettledPercent(),    // Settled
   455  			timestamp,                     // Time
   456  		})
   457  		if err != nil {
   458  			log.Errorf("error writing CSV: %v", err)
   459  			return
   460  		}
   461  		csvWriter.Flush()
   462  		err = csvWriter.Error()
   463  		if err != nil {
   464  			log.Errorf("error writing CSV: %v", err)
   465  			return
   466  		}
   467  	}
   468  }
   469  
   470  type orderTmplData struct {
   471  	CommonArguments
   472  	Order *core.OrderReader
   473  }
   474  
   475  // handleOrder is the handler for the /order/{oid} page request.
   476  func (s *WebServer) handleOrder(w http.ResponseWriter, r *http.Request) {
   477  	oid, err := getOrderIDCtx(r)
   478  	if err != nil {
   479  		log.Errorf("error retrieving order ID from request context: %v", err)
   480  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   481  		return
   482  	}
   483  	ord, err := s.core.Order(oid)
   484  	if err != nil {
   485  		log.Errorf("error retrieving order: %v", err)
   486  		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
   487  		return
   488  	}
   489  	s.sendTemplate(w, "order", &orderTmplData{
   490  		CommonArguments: *s.commonArgs(r, "Order | Bison Wallet"),
   491  		Order:           s.orderReader(ord),
   492  	})
   493  }
   494  
   495  func defaultUnitInfo(symbol string) dex.UnitInfo {
   496  	return dex.UnitInfo{
   497  		AtomicUnit: "atoms",
   498  		Conventional: dex.Denomination{
   499  			ConversionFactor: 1e8,
   500  			Unit:             symbol,
   501  		},
   502  	}
   503  }
   504  
   505  func (s *WebServer) orderReader(ord *core.Order) *core.OrderReader {
   506  	unitInfo := func(assetID uint32, symbol string) dex.UnitInfo {
   507  		unitInfo, err := asset.UnitInfo(assetID)
   508  		if err == nil {
   509  			return unitInfo
   510  		}
   511  		xc := s.core.Exchanges()[ord.Host]
   512  		a, found := xc.Assets[assetID]
   513  		if !found || a.UnitInfo.Conventional.ConversionFactor == 0 {
   514  			return defaultUnitInfo(symbol)
   515  		}
   516  		return a.UnitInfo
   517  	}
   518  
   519  	feeAssetInfo := func(assetID uint32, symbol string) (string, dex.UnitInfo) {
   520  		if token := asset.TokenInfo(assetID); token != nil {
   521  			parentAsset := asset.Asset(token.ParentID)
   522  			return unbip(parentAsset.ID), parentAsset.Info.UnitInfo
   523  		}
   524  		return unbip(assetID), unitInfo(assetID, symbol)
   525  	}
   526  
   527  	baseFeeAssetSymbol, baseFeeUintInfo := feeAssetInfo(ord.BaseID, ord.BaseSymbol)
   528  	quoteFeeAssetSymbol, quoteFeeUnitInfo := feeAssetInfo(ord.QuoteID, ord.QuoteSymbol)
   529  
   530  	return &core.OrderReader{
   531  		Order:               ord,
   532  		BaseUnitInfo:        unitInfo(ord.BaseID, ord.BaseSymbol),
   533  		BaseFeeUnitInfo:     baseFeeUintInfo,
   534  		BaseFeeAssetSymbol:  baseFeeAssetSymbol,
   535  		QuoteUnitInfo:       unitInfo(ord.QuoteID, ord.QuoteSymbol),
   536  		QuoteFeeUnitInfo:    quoteFeeUnitInfo,
   537  		QuoteFeeAssetSymbol: quoteFeeAssetSymbol,
   538  	}
   539  }