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", ®isterTmplData{ 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 }