decred.org/dcrdex@v1.0.3/client/webserver/webserver.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  	"context"
     8  	"crypto/elliptic"
     9  	crand "crypto/rand"
    10  	"crypto/tls"
    11  	"embed"
    12  	"encoding/hex"
    13  	"encoding/json"
    14  	"errors"
    15  	"fmt"
    16  	"io"
    17  	"io/fs"
    18  	"mime"
    19  	"net"
    20  	"net/http"
    21  	"os"
    22  	"path"
    23  	"path/filepath"
    24  	"runtime"
    25  	"strconv"
    26  	"strings"
    27  	"sync"
    28  	"sync/atomic"
    29  	"time"
    30  
    31  	"decred.org/dcrdex/client/asset"
    32  	"decred.org/dcrdex/client/core"
    33  	"decred.org/dcrdex/client/db"
    34  	"decred.org/dcrdex/client/mm"
    35  	"decred.org/dcrdex/client/mm/libxc"
    36  	"decred.org/dcrdex/client/webserver/locales"
    37  	"decred.org/dcrdex/client/websocket"
    38  	"decred.org/dcrdex/dex"
    39  	"decred.org/dcrdex/dex/encode"
    40  	"decred.org/dcrdex/dex/encrypt"
    41  	"github.com/decred/dcrd/certgen"
    42  	"github.com/go-chi/chi/v5"
    43  	"github.com/go-chi/chi/v5/middleware"
    44  	"golang.org/x/text/language"
    45  )
    46  
    47  // contextKey is the key param type used when saving values to a context using
    48  // context.WithValue. A custom type is defined because built-in types are
    49  // discouraged.
    50  type contextKey string
    51  
    52  const (
    53  	// httpConnTimeoutSeconds is the maximum number of seconds allowed for
    54  	// reading an http request or writing the response, beyond which the http
    55  	// connection is terminated.
    56  	httpConnTimeoutSeconds = 10
    57  	// authCK is the authorization token cookie key.
    58  	authCK = "dexauth"
    59  	// pwKeyCK is the cookie used to unencrypt the user's password.
    60  	pwKeyCK = "sessionkey"
    61  	// ctxKeyUserInfo is used in the authorization middleware for saving user
    62  	// info in http request contexts.
    63  	ctxKeyUserInfo = contextKey("userinfo")
    64  	// notifyRoute is a route used for general notifications.
    65  	notifyRoute = "notify"
    66  	// The basis for content-security-policy. connect-src must be the final
    67  	// directive so that it can be reliably supplemented on startup.
    68  	baseCSP = "default-src 'none'; script-src 'self'; img-src 'self' data:; style-src 'self'; font-src 'self'; connect-src 'self'"
    69  	// site is the common prefix for the site resources with respect to this
    70  	// webserver package.
    71  	site = "site"
    72  )
    73  
    74  var (
    75  	// errNoCachedPW is returned when attempting to retrieve a cached password, but the
    76  	// cookie that should contain the cached password is not populated.
    77  	errNoCachedPW = errors.New("no cached password")
    78  )
    79  
    80  var (
    81  	log   dex.Logger
    82  	unbip = dex.BipIDSymbol
    83  
    84  	//go:embed site/src/html/*.tmpl
    85  	htmlTmplRes    embed.FS
    86  	htmlTmplSub, _ = fs.Sub(htmlTmplRes, "site/src/html") // unrooted slash separated path as per io/fs.ValidPath
    87  
    88  	//go:embed site/dist site/src/img site/src/font
    89  	staticSiteRes embed.FS
    90  )
    91  
    92  // clientCore is satisfied by core.Core.
    93  type clientCore interface {
    94  	websocket.Core
    95  	Network() dex.Network
    96  	Exchanges() map[string]*core.Exchange
    97  	Exchange(host string) (*core.Exchange, error)
    98  	PostBond(form *core.PostBondForm) (*core.PostBondResult, error)
    99  	RedeemPrepaidBond(appPW []byte, code []byte, host string, certI any) (tier uint64, err error)
   100  	UpdateBondOptions(form *core.BondOptionsForm) error
   101  	Login(pw []byte) error
   102  	InitializeClient(pw []byte, seed *string) (string, error)
   103  	AssetBalance(assetID uint32) (*core.WalletBalance, error)
   104  	CreateWallet(appPW, walletPW []byte, form *core.WalletForm) error
   105  	OpenWallet(assetID uint32, pw []byte) error
   106  	RescanWallet(assetID uint32, force bool) error
   107  	RecoverWallet(assetID uint32, appPW []byte, force bool) error
   108  	CloseWallet(assetID uint32) error
   109  	ConnectWallet(assetID uint32) error
   110  	Wallets() []*core.WalletState
   111  	WalletState(assetID uint32) *core.WalletState
   112  	WalletSettings(uint32) (map[string]string, error)
   113  	ReconfigureWallet([]byte, []byte, *core.WalletForm) error
   114  	ToggleWalletStatus(assetID uint32, disable bool) error
   115  	ChangeAppPass([]byte, []byte) error
   116  	ResetAppPass(newPass []byte, seed string) error
   117  	NewDepositAddress(assetID uint32) (string, error)
   118  	AutoWalletConfig(assetID uint32, walletType string) (map[string]string, error)
   119  	User() *core.User
   120  	GetDEXConfig(dexAddr string, certI any) (*core.Exchange, error)
   121  	AddDEX(appPW []byte, dexAddr string, certI any) error
   122  	DiscoverAccount(dexAddr string, pass []byte, certI any) (*core.Exchange, bool, error)
   123  	SupportedAssets() map[uint32]*core.SupportedAsset
   124  	Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error)
   125  	Trade(pw []byte, form *core.TradeForm) (*core.Order, error)
   126  	TradeAsync(pw []byte, form *core.TradeForm) (*core.InFlightOrder, error)
   127  	Cancel(oid dex.Bytes) error
   128  	NotificationFeed() *core.NoteFeed
   129  	Logout() error
   130  	Orders(*core.OrderFilter) ([]*core.Order, error)
   131  	Order(oid dex.Bytes) (*core.Order, error)
   132  	MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error)
   133  	MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error)
   134  	AccountExport(pw []byte, host string) (*core.Account, []*db.Bond, error)
   135  	AccountImport(pw []byte, account *core.Account, bonds []*db.Bond) error
   136  	ToggleAccountStatus(pw []byte, host string, disable bool) error
   137  	IsInitialized() bool
   138  	ExportSeed(pw []byte) (string, error)
   139  	PreOrder(*core.TradeForm) (*core.OrderEstimate, error)
   140  	WalletLogFilePath(assetID uint32) (string, error)
   141  	BondsFeeBuffer(assetID uint32) (uint64, error)
   142  	PreAccelerateOrder(oidB dex.Bytes) (*core.PreAccelerate, error)
   143  	AccelerateOrder(pw []byte, oidB dex.Bytes, newFeeRate uint64) (string, error)
   144  	AccelerationEstimate(oidB dex.Bytes, newFeeRate uint64) (uint64, error)
   145  	UpdateCert(host string, cert []byte) error
   146  	UpdateDEXHost(oldHost, newHost string, appPW []byte, certI any) (*core.Exchange, error)
   147  	WalletRestorationInfo(pw []byte, assetID uint32) ([]*asset.WalletRestoration, error)
   148  	ToggleRateSourceStatus(src string, disable bool) error
   149  	FiatRateSources() map[string]bool
   150  	EstimateSendTxFee(address string, assetID uint32, value uint64, subtract, maxWithdraw bool) (fee uint64, isValidAddress bool, err error)
   151  	ValidateAddress(address string, assetID uint32) (bool, error)
   152  	DeleteArchivedRecordsWithBackup(olderThan *time.Time, saveMatchesToFile, saveOrdersToFile bool) (string, int, error)
   153  	WalletPeers(assetID uint32) ([]*asset.WalletPeer, error)
   154  	AddWalletPeer(assetID uint32, addr string) error
   155  	RemoveWalletPeer(assetID uint32, addr string) error
   156  	Notifications(n int) (notes, pokes []*db.Notification, _ error)
   157  	ApproveToken(appPW []byte, assetID uint32, dexAddr string, onConrim func()) (string, error)
   158  	UnapproveToken(appPW []byte, assetID uint32, version uint32) (string, error)
   159  	ApproveTokenFee(assetID uint32, version uint32, approval bool) (uint64, error)
   160  	StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error)
   161  	SetVSP(assetID uint32, addr string) error
   162  	PurchaseTickets(assetID uint32, pw []byte, n int) error
   163  	SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error
   164  	ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error)
   165  	TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error)
   166  	TxHistory(assetID uint32, n int, refID *string, past bool) ([]*asset.WalletTransaction, error)
   167  	FundsMixingStats(assetID uint32) (*asset.FundsMixingStats, error)
   168  	ConfigureFundsMixer(appPW []byte, assetID uint32, enabled bool) error
   169  	SetLanguage(string) error
   170  	Language() string
   171  	TakeAction(assetID uint32, actionID string, actionB json.RawMessage) error
   172  	RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64, error)
   173  	ExtensionModeConfig() *core.ExtensionModeConfig
   174  }
   175  
   176  type MMCore interface {
   177  	MarketReport(host string, base, quote uint32) (*mm.MarketReport, error)
   178  	StartBot(mkt *mm.StartConfig, alternateConfigPath *string, pw []byte) (err error)
   179  	StopBot(mkt *mm.MarketWithHost) error
   180  	UpdateCEXConfig(updatedCfg *mm.CEXConfig) error
   181  	CEXBalance(cexName string, assetID uint32) (*libxc.ExchangeBalance, error)
   182  	UpdateBotConfig(updatedCfg *mm.BotConfig) error
   183  	RemoveBotConfig(host string, baseID, quoteID uint32) error
   184  	Status() *mm.Status
   185  	ArchivedRuns() ([]*mm.MarketMakingRun, error)
   186  	RunOverview(startTime int64, mkt *mm.MarketWithHost) (*mm.MarketMakingRunOverview, error)
   187  	RunLogs(startTime int64, mkt *mm.MarketWithHost, n uint64, refID *uint64, filter *mm.RunLogFilters) (events, updatedEvents []*mm.MarketMakingEvent, overview *mm.MarketMakingRunOverview, err error)
   188  	CEXBook(host string, baseID, quoteID uint32) (buys, sells []*core.MiniOrder, _ error)
   189  }
   190  
   191  // genCertPair generates a key/cert pair to the paths provided.
   192  func genCertPair(certFile, keyFile string, altDNSNames []string) error {
   193  	log.Infof("Generating TLS certificates...")
   194  
   195  	org := "dex webserver autogenerated cert"
   196  	validUntil := time.Now().Add(390 * 24 * time.Hour) // https://blog.mozilla.org/security/2020/07/09/reducing-tls-certificate-lifespans-to-398-days/
   197  	cert, key, err := certgen.NewTLSCertPair(elliptic.P384(), org,
   198  		validUntil, altDNSNames)
   199  	if err != nil {
   200  		return err
   201  	}
   202  
   203  	// Write cert and key files.
   204  	if err = os.WriteFile(certFile, cert, 0644); err != nil {
   205  		return err
   206  	}
   207  	if err = os.WriteFile(keyFile, key, 0600); err != nil {
   208  		os.Remove(certFile)
   209  		return err
   210  	}
   211  
   212  	return nil
   213  }
   214  
   215  var _ clientCore = (*core.Core)(nil)
   216  
   217  // cachedPassword consists of the serialized crypter and an encrypted password.
   218  // A key stored in the cookies is used to deserialize the crypter, then the
   219  // crypter is used to decrypt the password.
   220  type cachedPassword struct {
   221  	EncryptedPass     []byte
   222  	SerializedCrypter []byte
   223  }
   224  
   225  type Config struct {
   226  	Core          clientCore // *core.Core
   227  	MarketMaker   MMCore     // *mm.MarketMaker
   228  	Addr          string
   229  	CustomSiteDir string
   230  	Language      string
   231  	Logger        dex.Logger
   232  	UTC           bool // for stdout http request logging
   233  	CertFile      string
   234  	KeyFile       string
   235  	// NoEmbed indicates to serve files from the system disk rather than the
   236  	// embedded files. Since this is a developer setting, this also implies
   237  	// reloading of templates on each request. Note that only embedded files
   238  	// should be used by default since site files from older distributions may
   239  	// be present on the disk. When NoEmbed is true, this also implies reloading
   240  	// and execution of html templates on each request.
   241  	NoEmbed  bool
   242  	HttpProf bool
   243  }
   244  
   245  type valStamp struct {
   246  	val   uint64
   247  	stamp time.Time
   248  }
   249  
   250  // WebServer is a single-client http and websocket server enabling a browser
   251  // interface to Bison Wallet.
   252  type WebServer struct {
   253  	ctx      context.Context
   254  	wsServer *websocket.Server
   255  	mux      *chi.Mux
   256  	siteDir  string
   257  	lang     atomic.Value // string
   258  	langs    []string
   259  	core     clientCore
   260  	mm       MMCore
   261  	addr     string
   262  	csp      string
   263  	srv      *http.Server
   264  	html     atomic.Value // *templates
   265  
   266  	authMtx         sync.RWMutex
   267  	authTokens      map[string]bool
   268  	cachedPasswords map[string]*cachedPassword // cached passwords keyed by auth token
   269  
   270  	bondBufMtx sync.Mutex
   271  	bondBuf    map[uint32]valStamp
   272  
   273  	useDEXBranding bool
   274  }
   275  
   276  // New is the constructor for a new WebServer. CustomSiteDir in the Config can
   277  // be left blank, in which case a handful of default locations will be checked.
   278  // This will work in most cases.
   279  func New(cfg *Config) (*WebServer, error) {
   280  	log = cfg.Logger
   281  
   282  	// Only look for files on disk if NoEmbed is set. This is necessary since
   283  	// site files from older distributions may be present.
   284  	var siteDir string // empty signals embedded files only
   285  	if cfg.NoEmbed {
   286  		// Look for the "site" folder in the executable's path, the working
   287  		// directory, or relative to [repo root]/client/cmd/bisonw.
   288  		execPath, err := os.Executable() // e.g. /usr/bin/bisonw
   289  		if err != nil {
   290  			return nil, fmt.Errorf("unable to locate executable path: %w", err)
   291  		}
   292  		execPath, err = filepath.EvalSymlinks(execPath) // e.g. /opt/decred/dex/bisonw
   293  		if err != nil {
   294  			return nil, fmt.Errorf("unable to locate executable path: %w", err)
   295  		}
   296  		execPath = filepath.Dir(execPath) // e.g. /opt/decred/dex
   297  
   298  		absDir, _ := filepath.Abs(site)
   299  		for _, dir := range []string{
   300  			cfg.CustomSiteDir,
   301  			filepath.Join(execPath, site),
   302  			absDir,
   303  			filepath.Clean(filepath.Join(execPath, "../../webserver/site")),
   304  		} {
   305  			if dir == "" {
   306  				continue
   307  			}
   308  			log.Debugf("Looking for site in %s", dir)
   309  			if folderExists(dir) {
   310  				siteDir = dir
   311  				break
   312  			}
   313  		}
   314  
   315  		if siteDir == "" {
   316  			return nil, fmt.Errorf("no HTML template files found. "+
   317  				"Place the 'site' folder in the executable's directory %q or the working directory, "+
   318  				"or run bisonw from within the client/cmd/bisonw source workspace folder, or specify the"+
   319  				"'sitedir' configuration directive to bisonw.", execPath)
   320  		}
   321  
   322  		log.Infof("Located \"site\" folder at %v", siteDir)
   323  	} else {
   324  		// Developer should remember to rebuild the Go binary if they modify any
   325  		// frontend files, otherwise they should run with --no-embed-site.
   326  		log.Debugf("Using embedded site resources.")
   327  	}
   328  
   329  	// Create an HTTP router.
   330  	mux := chi.NewRouter()
   331  	httpServer := &http.Server{
   332  		Handler:      mux,
   333  		ReadTimeout:  httpConnTimeoutSeconds * time.Second, // slow requests should not hold connections opened
   334  		WriteTimeout: 2 * time.Minute,                      // request to response time, must be long enough for slow handlers
   335  	}
   336  
   337  	if cfg.CertFile != "" || cfg.KeyFile != "" {
   338  		// Find or create the key pair.
   339  		keyExists := dex.FileExists(cfg.KeyFile)
   340  		certExists := dex.FileExists(cfg.CertFile)
   341  		if certExists != keyExists {
   342  			return nil, fmt.Errorf("missing cert pair file")
   343  		}
   344  		if !keyExists {
   345  			if err := genCertPair(cfg.CertFile, cfg.KeyFile, []string{cfg.Addr}); err != nil {
   346  				return nil, err
   347  			}
   348  			// TODO: generate a separate CA certificate. Browsers don't like
   349  			// that the site certificate is also a CA.
   350  		}
   351  		keyPair, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
   352  		if err != nil {
   353  			return nil, err
   354  		}
   355  		log.Infof("Using HTTPS with certificate %v and key %v. "+
   356  			"You may import the certificate as an authority (CA) in your browser, "+
   357  			"or override the warning about a self-signed certificate. "+
   358  			"Delete both files to regenerate them on next startup.",
   359  			cfg.CertFile, cfg.KeyFile)
   360  		httpServer.TLSConfig = &tls.Config{
   361  			ServerName:   cfg.Addr,
   362  			Certificates: []tls.Certificate{keyPair},
   363  			MinVersion:   tls.VersionTLS12,
   364  		}
   365  		// Uncomment to disable HTTP/2:
   366  		// httpServer.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler), 0)
   367  	}
   368  
   369  	lang := cfg.Core.Language()
   370  
   371  	langs := make([]string, 0, len(localesMap))
   372  	for l := range localesMap {
   373  		langs = append(langs, l)
   374  	}
   375  
   376  	var useDEXBranding bool
   377  	if xCfg := cfg.Core.ExtensionModeConfig(); xCfg != nil {
   378  		useDEXBranding = xCfg.UseDEXBranding
   379  	}
   380  
   381  	// Make the server here so its methods can be registered.
   382  	s := &WebServer{
   383  		langs:           langs,
   384  		core:            cfg.Core,
   385  		mm:              cfg.MarketMaker,
   386  		siteDir:         siteDir,
   387  		mux:             mux,
   388  		srv:             httpServer,
   389  		addr:            cfg.Addr,
   390  		wsServer:        websocket.New(cfg.Core, log.SubLogger("WS")),
   391  		authTokens:      make(map[string]bool),
   392  		cachedPasswords: make(map[string]*cachedPassword),
   393  		bondBuf:         map[uint32]valStamp{},
   394  		useDEXBranding:  useDEXBranding,
   395  	}
   396  	s.lang.Store(lang)
   397  
   398  	if err := s.buildTemplates(lang); err != nil {
   399  		return nil, fmt.Errorf("error loading localized html templates: %v", err)
   400  	}
   401  
   402  	// Middleware
   403  	mux.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{
   404  		Logger: &chiLogger{ // logs with Trace()
   405  			Logger: dex.StdOutLogger("MUX", log.Level(), cfg.UTC),
   406  		},
   407  		NoColor: runtime.GOOS == "windows",
   408  	}))
   409  	mux.Use(s.securityMiddleware)
   410  	mux.Use(middleware.Recoverer)
   411  
   412  	// HTTP profiler
   413  	if cfg.HttpProf {
   414  		profPath := "/debug/pprof"
   415  		log.Infof("Mounting the HTTP profiler on %s", profPath)
   416  		// Option A: mount each httpprof handler directly. The caveat with this
   417  		// is that httpprof.Index ONLY works when mounted on /debug/pprof/.
   418  		//
   419  		// mux.Mount(profPath, http.HandlerFunc(httppprof.Index)) // also
   420  		// handles: goroutine, heap, threadcreate, block, allocs, mutex
   421  		// mux.Mount(profPath+"/cmdline", http.HandlerFunc(httppprof.Cmdline))
   422  		// mux.Mount(profPath+"/profile", http.HandlerFunc(httppprof.Profile))
   423  		// mux.Mount(profPath+"/symbol", http.HandlerFunc(httppprof.Symbol))
   424  		// mux.Mount(profPath+"/trace", http.HandlerFunc(httppprof.Trace))
   425  
   426  		// Option B: http pprof uses http.DefaultServeMux, so mount it:
   427  		mux.Mount(profPath, http.DefaultServeMux) // profPath MUST be /debug/pprof this way
   428  	}
   429  
   430  	// The WebSocket handler is mounted on /ws in Connect.
   431  
   432  	// Webpages
   433  	mux.Group(func(web chi.Router) {
   434  		// Inject user info for handlers that use extractUserInfo, which
   435  		// includes most of the page handlers that use commonArgs to
   436  		// inject the User object for page template execution.
   437  		web.Use(s.authMiddleware)
   438  		web.Get(settingsRoute, s.handleSettings)
   439  
   440  		web.Get("/generateqrcode", s.handleGenerateQRCode)
   441  
   442  		web.Group(func(notInit chi.Router) {
   443  			notInit.Use(s.requireNotInit)
   444  			notInit.Get(initRoute, s.handleInit)
   445  		})
   446  
   447  		// The rest of the web handlers require initialization.
   448  		web.Group(func(webInit chi.Router) {
   449  			webInit.Use(s.requireInit)
   450  
   451  			webInit.Route(registerRoute, func(rr chi.Router) {
   452  				rr.Get("/", s.handleRegister)
   453  				rr.With(dexHostCtx).Get("/{host}", s.handleRegister)
   454  			})
   455  
   456  			webInit.Group(func(webNoAuth chi.Router) {
   457  				// The login handler requires init but not auth since
   458  				// it performs the auth.
   459  				webNoAuth.Get(loginRoute, s.handleLogin)
   460  
   461  				// The rest of these handlers require both init and auth.
   462  				webNoAuth.Group(func(webAuth chi.Router) {
   463  					webAuth.Use(s.requireLogin)
   464  					webAuth.Get(homeRoute, s.handleHome)
   465  					webAuth.Get(walletsRoute, s.handleWallets)
   466  					webAuth.Get(walletLogRoute, s.handleWalletLogFile)
   467  				})
   468  			})
   469  
   470  			// Handlers requiring a DEX connection.
   471  			webInit.Group(func(webDC chi.Router) {
   472  				webDC.Use(s.requireDEXConnection, s.requireLogin)
   473  				webDC.With(orderIDCtx).Get("/order/{oid}", s.handleOrder)
   474  				webDC.Get(ordersRoute, s.handleOrders)
   475  				webDC.Get(exportOrderRoute, s.handleExportOrders)
   476  				webDC.Get(marketsRoute, s.handleMarkets)
   477  				webDC.Get(mmSettingsRoute, s.handleMMSettings)
   478  				webDC.Get(mmArchivesRoute, s.handleMMArchives)
   479  				webDC.Get(mmLogsRoute, s.handleMMLogs)
   480  				webDC.Get(marketMakerRoute, s.handleMarketMaking)
   481  				webDC.With(dexHostCtx).Get("/dexsettings/{host}", s.handleDexSettings)
   482  			})
   483  
   484  		})
   485  	})
   486  
   487  	// api endpoints
   488  	mux.Route("/api", func(r chi.Router) {
   489  		r.Use(middleware.AllowContentType("application/json"))
   490  		r.Post("/init", s.apiInit)
   491  		r.Get("/isinitialized", s.apiIsInitialized)
   492  		r.Post("/resetapppassword", s.apiResetAppPassword)
   493  		r.Get("/user", s.apiUser)
   494  		r.Post("/locale", s.apiLocale)
   495  		r.Post("/setlocale", s.apiSetLocale)
   496  
   497  		r.Group(func(apiInit chi.Router) {
   498  			apiInit.Use(s.rejectUninited)
   499  			apiInit.Post("/login", s.apiLogin)
   500  			apiInit.Post("/getdexinfo", s.apiGetDEXInfo) // TODO: Seems unused.
   501  			apiInit.Post("/adddex", s.apiAddDEX)
   502  			apiInit.Post("/discoveracct", s.apiDiscoverAccount)
   503  			apiInit.Post("/bondsfeebuffer", s.apiBondsFeeBuffer)
   504  		})
   505  
   506  		r.Group(func(apiAuth chi.Router) {
   507  			apiAuth.Use(s.rejectUnauthed)
   508  			apiAuth.Get("/notes", s.apiNotes)
   509  			apiAuth.Post("/defaultwalletcfg", s.apiDefaultWalletCfg)
   510  			apiAuth.Post("/postbond", s.apiPostBond)
   511  			apiAuth.Post("/updatebondoptions", s.apiUpdateBondOptions)
   512  			apiAuth.Post("/redeemprepaidbond", s.apiRedeemPrepaidBond)
   513  			apiAuth.Post("/newwallet", s.apiNewWallet)
   514  			apiAuth.Post("/openwallet", s.apiOpenWallet)
   515  			apiAuth.Post("/depositaddress", s.apiNewDepositAddress)
   516  			apiAuth.Post("/closewallet", s.apiCloseWallet)
   517  			apiAuth.Post("/connectwallet", s.apiConnectWallet)
   518  			apiAuth.Post("/rescanwallet", s.apiRescanWallet)
   519  			apiAuth.Post("/recoverwallet", s.apiRecoverWallet)
   520  			apiAuth.Post("/trade", s.apiTrade)
   521  			apiAuth.Post("/tradeasync", s.apiTradeAsync)
   522  			apiAuth.Post("/cancel", s.apiCancel)
   523  			apiAuth.Post("/logout", s.apiLogout)
   524  			apiAuth.Post("/balance", s.apiGetBalance)
   525  			apiAuth.Post("/parseconfig", s.apiParseConfig)
   526  			apiAuth.Post("/reconfigurewallet", s.apiReconfig)
   527  			apiAuth.Post("/changeapppass", s.apiChangeAppPass)
   528  			apiAuth.Post("/walletsettings", s.apiWalletSettings)
   529  			apiAuth.Post("/togglewalletstatus", s.apiToggleWalletStatus)
   530  			apiAuth.Post("/orders", s.apiOrders)
   531  			apiAuth.Post("/order", s.apiOrder)
   532  			apiAuth.Post("/send", s.apiSend)
   533  			apiAuth.Post("/maxbuy", s.apiMaxBuy)
   534  			apiAuth.Post("/maxsell", s.apiMaxSell)
   535  			apiAuth.Post("/preorder", s.apiPreOrder)
   536  			apiAuth.Post("/exportaccount", s.apiAccountExport)
   537  			apiAuth.Post("/exportseed", s.apiExportSeed)
   538  			apiAuth.Post("/importaccount", s.apiAccountImport)
   539  			apiAuth.Post("/toggleaccountstatus", s.apiToggleAccountStatus)
   540  			apiAuth.Post("/accelerateorder", s.apiAccelerateOrder)
   541  			apiAuth.Post("/preaccelerate", s.apiPreAccelerate)
   542  			apiAuth.Post("/accelerationestimate", s.apiAccelerationEstimate)
   543  			apiAuth.Post("/updatecert", s.apiUpdateCert)
   544  			apiAuth.Post("/updatedexhost", s.apiUpdateDEXHost)
   545  			apiAuth.Post("/restorewalletinfo", s.apiRestoreWalletInfo)
   546  			apiAuth.Post("/toggleratesource", s.apiToggleRateSource)
   547  			apiAuth.Post("/validateaddress", s.apiValidateAddress)
   548  			apiAuth.Post("/txfee", s.apiEstimateSendTxFee)
   549  			apiAuth.Post("/deletearchivedrecords", s.apiDeleteArchivedRecords)
   550  			apiAuth.Post("/getwalletpeers", s.apiGetWalletPeers)
   551  			apiAuth.Post("/addwalletpeer", s.apiAddWalletPeer)
   552  			apiAuth.Post("/removewalletpeer", s.apiRemoveWalletPeer)
   553  			apiAuth.Post("/approvetoken", s.apiApproveToken)
   554  			apiAuth.Post("/unapprovetoken", s.apiUnapproveToken)
   555  			apiAuth.Post("/approvetokenfee", s.apiApproveTokenFee)
   556  			apiAuth.Post("/txhistory", s.apiTxHistory)
   557  			apiAuth.Post("/takeaction", s.apiTakeAction)
   558  			apiAuth.Post("/redeemgamecode", s.redeemGameCode)
   559  
   560  			apiAuth.Post("/stakestatus", s.apiStakeStatus)
   561  			apiAuth.Post("/setvsp", s.apiSetVSP)
   562  			apiAuth.Post("/purchasetickets", s.apiPurchaseTickets)
   563  			apiAuth.Post("/setvotes", s.apiSetVotingPreferences)
   564  			apiAuth.Post("/listvsps", s.apiListVSPs)
   565  			apiAuth.Post("/ticketpage", s.apiTicketPage)
   566  
   567  			apiAuth.Post("/mixingstats", s.apiMixingStats)
   568  			apiAuth.Post("/configuremixer", s.apiConfigureMixer)
   569  
   570  			apiAuth.Post("/startmarketmakingbot", s.apiStartMarketMakingBot)
   571  			apiAuth.Post("/stopmarketmakingbot", s.apiStopMarketMakingBot)
   572  			apiAuth.Post("/updatebotconfig", s.apiUpdateBotConfig)
   573  			apiAuth.Post("/updatecexconfig", s.apiUpdateCEXConfig)
   574  			apiAuth.Post("/removebotconfig", s.apiRemoveBotConfig)
   575  			apiAuth.Get("/marketmakingstatus", s.apiMarketMakingStatus)
   576  			apiAuth.Post("/marketreport", s.apiMarketReport)
   577  			apiAuth.Post("/cexbalance", s.apiCEXBalance)
   578  			apiAuth.Get("/archivedmmruns", s.apiArchivedRuns)
   579  			apiAuth.Post("/mmrunlogs", s.apiRunLogs)
   580  			apiAuth.Post("/cexbook", s.apiCEXBook)
   581  		})
   582  	})
   583  
   584  	// Files
   585  	fileServer(mux, "/js", siteDir, "dist", "text/javascript")
   586  	fileServer(mux, "/css", siteDir, "dist", "text/css")
   587  	fileServer(mux, "/img", siteDir, "src/img", "")
   588  	fileServer(mux, "/font", siteDir, "src/font", "")
   589  
   590  	return s, nil
   591  }
   592  
   593  // buildTemplates prepares the HTML templates, which are executed and served in
   594  // sendTemplate. An empty siteDir indicates that the embedded templates in the
   595  // htmlTmplSub FS should be used. If siteDir is set, the templates will be
   596  // loaded from disk.
   597  func (s *WebServer) buildTemplates(lang string) error {
   598  	// Try to identify language.
   599  	acceptLang, err := language.Parse(lang)
   600  	if err != nil {
   601  		return fmt.Errorf("unable to parse requested language: %v", err)
   602  	}
   603  
   604  	// Find acceptable match with available locales.
   605  	langTags := make([]language.Tag, 0, len(locales.Locales))
   606  	localeNames := make([]string, 0, len(locales.Locales))
   607  	for localeName := range locales.Locales {
   608  		lang, _ := language.Parse(localeName) // checked in init()
   609  		langTags = append(langTags, lang)
   610  		localeNames = append(localeNames, localeName)
   611  	}
   612  	_, idx, conf := language.NewMatcher(langTags).Match(acceptLang)
   613  	localeName := localeNames[idx] // use index because tag may end up as something hyper specific like zh-Hans-u-rg-cnzzzz
   614  	switch conf {
   615  	case language.Exact, language.High, language.Low:
   616  		log.Infof("Using language %v", localeName)
   617  	case language.No:
   618  		return fmt.Errorf("no match for %q in recognized languages %v", lang, localeNames)
   619  	}
   620  
   621  	var htmlDir string
   622  	if s.siteDir == "" {
   623  		log.Infof("Using embedded HTML templates")
   624  	} else {
   625  		htmlDir = filepath.Join(s.siteDir, "src", "html")
   626  		log.Infof("Using HTML templates in %s", htmlDir)
   627  	}
   628  
   629  	bb := "bodybuilder"
   630  	html := newTemplates(htmlDir, localeName).
   631  		addTemplate("login", bb, "forms").
   632  		addTemplate("register", bb, "forms").
   633  		addTemplate("markets", bb, "forms").
   634  		addTemplate("wallets", bb, "forms").
   635  		addTemplate("settings", bb, "forms").
   636  		addTemplate("orders", bb).
   637  		addTemplate("order", bb, "forms").
   638  		addTemplate("dexsettings", bb, "forms").
   639  		addTemplate("init", bb).
   640  		addTemplate("mm", bb, "forms").
   641  		addTemplate("mmsettings", bb, "forms").
   642  		addTemplate("mmarchives", bb).
   643  		addTemplate("mmlogs", bb)
   644  	s.html.Store(html)
   645  
   646  	return html.buildErr()
   647  }
   648  
   649  // Addr gives the address on which WebServer is listening. Use only after
   650  // Connect.
   651  func (s *WebServer) Addr() string {
   652  	return s.addr
   653  }
   654  
   655  // Connect starts the web server. Satisfies the dex.Connector interface.
   656  func (s *WebServer) Connect(ctx context.Context) (*sync.WaitGroup, error) {
   657  	// Start serving.
   658  	listener, err := net.Listen("tcp", s.addr)
   659  	if err != nil {
   660  		return nil, fmt.Errorf("Can't listen on %s. web server quitting: %w", s.addr, err)
   661  	}
   662  	https := s.srv.TLSConfig != nil
   663  	if https {
   664  		listener = tls.NewListener(listener, s.srv.TLSConfig)
   665  	}
   666  
   667  	s.ctx = ctx
   668  
   669  	addr, allowInCSP := prepareAddr(listener.Addr())
   670  	if allowInCSP {
   671  		// Work around a webkit (safari) bug with the handling of the
   672  		// connect-src directive of content security policy. See:
   673  		// https://bugs.webkit.org/show_bug.cgi?id=201591. TODO: Remove this
   674  		// workaround since the issue has been fixed in newer versions of
   675  		// Safari. When this is removed, the allowInCSP variable can be removed
   676  		// but prepareAddr should still return 127.0.0.1 for unspecified
   677  		// addresses.
   678  		scheme := "ws"
   679  		if s.srv.TLSConfig != nil {
   680  			scheme = "wss"
   681  		}
   682  		s.csp = fmt.Sprintf("%s %s://%s", baseCSP, scheme, addr)
   683  	}
   684  	s.addr = addr
   685  
   686  	// Shutdown the server on context cancellation.
   687  	var wg sync.WaitGroup
   688  	wg.Add(1)
   689  	go func() {
   690  		defer wg.Done()
   691  		<-ctx.Done()
   692  		err := s.srv.Shutdown(context.Background())
   693  		if err != nil {
   694  			log.Errorf("Problem shutting down rpc: %v", err)
   695  		}
   696  	}()
   697  
   698  	// Configure the websocket handler before starting the server.
   699  	s.mux.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
   700  		s.wsServer.HandleConnect(ctx, w, r)
   701  	})
   702  
   703  	wg.Add(1)
   704  	go func() {
   705  		defer wg.Done()
   706  		err = s.srv.Serve(listener) // will modify srv.TLSConfig for http/2 even when !https
   707  		if !errors.Is(err, http.ErrServerClosed) {
   708  			log.Warnf("unexpected (http.Server).Serve error: %v", err)
   709  		}
   710  		// Disconnect the websocket clients since http.(*Server).Shutdown does
   711  		// not deal with hijacked websocket connections.
   712  		s.wsServer.Shutdown()
   713  		log.Infof("Web server off")
   714  	}()
   715  
   716  	wg.Add(1)
   717  	go func() {
   718  		defer wg.Done()
   719  		s.readNotifications(ctx)
   720  	}()
   721  
   722  	log.Infof("Web server listening on %s (https = %v)", s.addr, https)
   723  	scheme := "http"
   724  	if https {
   725  		scheme = "https"
   726  	}
   727  	fmt.Printf("\n\t****  OPEN IN YOUR BROWSER TO LOGIN AND TRADE  --->  %s://%s  ****\n\n",
   728  		scheme, s.addr)
   729  	return &wg, nil
   730  }
   731  
   732  // prepareAddr prepares the listening address in case a :0 was provided.
   733  func prepareAddr(addr net.Addr) (string, bool) {
   734  	// If the IP is unspecified, default to `127.0.0.1`. This is a workaround
   735  	// for an issue where all ip addresses other than exactly 127.0.0.1 will
   736  	// always fail to match when used in CSP directives. See:
   737  	// https://w3c.github.io/webappsec-csp/#match-hosts.
   738  	defaultIP := net.IP{127, 0, 0, 1}
   739  	tcpAddr, ok := addr.(*net.TCPAddr)
   740  	if ok && (tcpAddr.IP.IsUnspecified() || tcpAddr.IP.Equal(defaultIP)) {
   741  		return net.JoinHostPort(defaultIP.String(), strconv.Itoa(tcpAddr.Port)), true
   742  	}
   743  
   744  	return addr.String(), false
   745  }
   746  
   747  // authorize creates, stores, and returns a new auth token to identify the user.
   748  // deauth should be used to invalidate tokens on logout.
   749  func (s *WebServer) authorize() string {
   750  	b := make([]byte, 32)
   751  	crand.Read(b)
   752  	token := hex.EncodeToString(b)
   753  	zero(b)
   754  	s.authMtx.Lock()
   755  	s.authTokens[token] = true
   756  	s.authMtx.Unlock()
   757  	return token
   758  }
   759  
   760  // deauth invalidates all current auth tokens. All existing sessions will need
   761  // to login again.
   762  func (s *WebServer) deauth() {
   763  	s.authMtx.Lock()
   764  	s.authTokens = make(map[string]bool)
   765  	s.cachedPasswords = make(map[string]*cachedPassword)
   766  	s.authMtx.Unlock()
   767  }
   768  
   769  // getAuthToken checks the request for an auth token cookie and returns it.
   770  // An empty string is returned if there is no auth token cookie.
   771  func getAuthToken(r *http.Request) string {
   772  	var authToken string
   773  	cookie, err := r.Cookie(authCK)
   774  	switch {
   775  	case err == nil:
   776  		authToken = cookie.Value
   777  	case errors.Is(err, http.ErrNoCookie):
   778  	default:
   779  		log.Errorf("authToken retrieval error: %v", err)
   780  	}
   781  
   782  	return authToken
   783  }
   784  
   785  // getPWKey checks the request for a password key cookie. Returns an error
   786  // if it does not exist or it is not valid.
   787  func getPWKey(r *http.Request) ([]byte, error) {
   788  	cookie, err := r.Cookie(pwKeyCK)
   789  	switch {
   790  	case err == nil:
   791  		sessionKey, err := hex.DecodeString(cookie.Value)
   792  		if err != nil {
   793  			return nil, err
   794  		}
   795  		return sessionKey, nil
   796  	case errors.Is(err, http.ErrNoCookie):
   797  		return nil, nil
   798  	default:
   799  		return nil, err
   800  	}
   801  }
   802  
   803  // isAuthed checks if the incoming request is from an authorized user/device.
   804  // Requires the auth token cookie to be set in the request and for the token
   805  // to match `WebServer.validAuthToken`.
   806  func (s *WebServer) isAuthed(r *http.Request) bool {
   807  	authToken := getAuthToken(r)
   808  	if authToken == "" {
   809  		return false
   810  	}
   811  	s.authMtx.RLock()
   812  	defer s.authMtx.RUnlock()
   813  	return s.authTokens[authToken]
   814  }
   815  
   816  // getCachedPassword retrieves the cached password for the user identified by authToken and
   817  // presenting the specified key in their cookies.
   818  func (s *WebServer) getCachedPassword(key []byte, authToken string) ([]byte, error) {
   819  	s.authMtx.Lock()
   820  	cachedPassword, ok := s.cachedPasswords[authToken]
   821  	s.authMtx.Unlock()
   822  	if !ok {
   823  		return nil, fmt.Errorf("cached encrypted password not found for"+
   824  			" auth token: %v", authToken)
   825  	}
   826  
   827  	crypter, err := encrypt.Deserialize(key, cachedPassword.SerializedCrypter)
   828  	if err != nil {
   829  		return nil, fmt.Errorf("error deserializing crypter: %w", err)
   830  	}
   831  
   832  	pw, err := crypter.Decrypt(cachedPassword.EncryptedPass)
   833  	if err != nil {
   834  		return nil, fmt.Errorf("error decrypting password: %w", err)
   835  	}
   836  
   837  	return pw, nil
   838  }
   839  
   840  // getCachedPasswordUsingRequest retrieves the cached password using the information
   841  // in the request.
   842  func (s *WebServer) getCachedPasswordUsingRequest(r *http.Request) ([]byte, error) {
   843  	authToken := getAuthToken(r)
   844  	if authToken == "" {
   845  		return nil, errNoCachedPW
   846  	}
   847  	pwKeyBlob, err := getPWKey(r)
   848  	if err != nil {
   849  		return nil, err
   850  	}
   851  	if pwKeyBlob == nil {
   852  		return nil, errNoCachedPW
   853  	}
   854  	return s.getCachedPassword(pwKeyBlob, authToken)
   855  }
   856  
   857  // cacheAppPassword encrypts the app password with a random encryption key and returns the key.
   858  // The authToken is used to lookup the encrypted password when calling getCachedPassword.
   859  func (s *WebServer) cacheAppPassword(appPW []byte, authToken string) ([]byte, error) {
   860  	key := encode.RandomBytes(16)
   861  	crypter := encrypt.NewCrypter(key)
   862  	defer crypter.Close()
   863  	encryptedPass, err := crypter.Encrypt(appPW)
   864  	if err != nil {
   865  		return nil, fmt.Errorf("error encrypting password: %v", err)
   866  	}
   867  
   868  	s.authMtx.Lock()
   869  	s.cachedPasswords[authToken] = &cachedPassword{
   870  		EncryptedPass:     encryptedPass,
   871  		SerializedCrypter: crypter.Serialize(),
   872  	}
   873  	s.authMtx.Unlock()
   874  	return key, nil
   875  }
   876  
   877  // isPasswordCached checks if a password can be retrieved from the encrypted
   878  // password cache using the information in the request.
   879  func (s *WebServer) isPasswordCached(r *http.Request) bool {
   880  	_, err := s.getCachedPasswordUsingRequest(r)
   881  	return err == nil
   882  }
   883  
   884  // readNotifications reads from the Core notification channel and relays to
   885  // websocket clients.
   886  func (s *WebServer) readNotifications(ctx context.Context) {
   887  	ch := s.core.NotificationFeed()
   888  	defer ch.ReturnFeed()
   889  
   890  	for {
   891  		select {
   892  		case n := <-ch.C:
   893  			s.wsServer.Notify(notifyRoute, n)
   894  		case <-ctx.Done():
   895  			return
   896  		}
   897  	}
   898  }
   899  
   900  // readPost unmarshals the request body into the provided interface.
   901  func readPost(w http.ResponseWriter, r *http.Request, thing any) bool {
   902  	body, err := io.ReadAll(r.Body)
   903  	r.Body.Close()
   904  	if err != nil {
   905  		log.Debugf("Error reading request body: %v", err)
   906  		http.Error(w, "error reading JSON message", http.StatusBadRequest)
   907  		return false
   908  	}
   909  	err = json.Unmarshal(body, thing)
   910  	if err != nil {
   911  		log.Debugf("failed to unmarshal JSON request: %v", err)
   912  		log.Debugf("raw request: %s", string(body))
   913  		http.Error(w, "failed to unmarshal JSON request", http.StatusBadRequest)
   914  		return false
   915  	}
   916  	return true
   917  }
   918  
   919  // userInfo is information about the connected user. This type embeds the
   920  // core.User type, adding fields specific to the users server authentication
   921  // and cookies.
   922  type userInfo struct {
   923  	Authed           bool
   924  	PasswordIsCached bool
   925  }
   926  
   927  // Extract the userInfo from the request context. This should be used with
   928  // authMiddleware.
   929  func extractUserInfo(r *http.Request) *userInfo {
   930  	user, ok := r.Context().Value(ctxKeyUserInfo).(*userInfo)
   931  	if !ok {
   932  		log.Errorf("no auth info retrieved from client")
   933  		return &userInfo{}
   934  	}
   935  	return user
   936  }
   937  
   938  func serveFile(w http.ResponseWriter, r *http.Request, fullFilePath string) {
   939  	// Generate the full file system path and test for existence.
   940  	fi, err := os.Stat(fullFilePath)
   941  	if err != nil {
   942  		http.NotFound(w, r)
   943  		return
   944  	}
   945  
   946  	// Deny directory listings
   947  	if fi.IsDir() {
   948  		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
   949  		return
   950  	}
   951  
   952  	http.ServeFile(w, r, fullFilePath)
   953  }
   954  
   955  // fileServer is a file server for files in subDir with the parent folder
   956  // siteDire. An empty siteDir means embedded files only are served. The
   957  // pathPrefix is stripped from the request path when locating the file.
   958  func fileServer(r chi.Router, pathPrefix, siteDir, subDir, forceContentType string) {
   959  	if strings.ContainsAny(pathPrefix, "{}*") {
   960  		panic("FileServer does not permit URL parameters.")
   961  	}
   962  
   963  	// Define a http.HandlerFunc to serve files but not directory indexes.
   964  	hf := func(w http.ResponseWriter, r *http.Request) {
   965  		// Ensure the path begins with "/".
   966  		upath := r.URL.Path
   967  		if strings.Contains(upath, "..") {
   968  			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
   969  			return
   970  		}
   971  		if !strings.HasPrefix(upath, "/") {
   972  			upath = "/" + upath
   973  			r.URL.Path = upath
   974  		}
   975  		// Strip the path prefix and clean the path.
   976  		upath = path.Clean(strings.TrimPrefix(upath, pathPrefix))
   977  
   978  		// Deny directory listings (http.ServeFile recognizes index.html and
   979  		// attempts to serve the directory contents instead).
   980  		if strings.HasSuffix(upath, "/index.html") {
   981  			http.NotFound(w, r)
   982  			return
   983  		}
   984  
   985  		// On Windows, a common registry misconfiguration leads to
   986  		// mime.TypeByExtension setting an incorrect type for .js files, causing
   987  		// the browser to refuse to execute the JavaScript. The following
   988  		// workaround may be removed when Go 1.19 becomes the minimum required
   989  		// version: https://go-review.googlesource.com/c/go/+/406894
   990  		// https://github.com/golang/go/issues/32350
   991  		if forceContentType != "" {
   992  			w.Header().Set("Content-Type", forceContentType)
   993  		}
   994  
   995  		// If siteDir is set, use system file system only.
   996  		if siteDir != "" {
   997  			fullFilePath := filepath.Join(siteDir, subDir, upath)
   998  			serveFile(w, r, fullFilePath)
   999  			return
  1000  		}
  1001  
  1002  		// Use the embedded files only.
  1003  		fs := http.FS(staticSiteRes) // so f is an http.File instead of fs.File
  1004  		f, err := fs.Open(path.Join(site, subDir, upath))
  1005  		if err != nil {
  1006  			http.NotFound(w, r)
  1007  			return
  1008  		}
  1009  		defer f.Close()
  1010  
  1011  		// return in case it is a directory
  1012  		stat, err := f.Stat()
  1013  		if err != nil {
  1014  			http.Error(w, err.Error(), http.StatusInternalServerError)
  1015  			return
  1016  		}
  1017  		if stat.IsDir() {
  1018  			http.NotFound(w, r)
  1019  			return
  1020  		}
  1021  
  1022  		if forceContentType == "" {
  1023  			// http.ServeFile would do the following type detection.
  1024  			contentType := mime.TypeByExtension(filepath.Ext(upath))
  1025  			if contentType == "" {
  1026  				// Sniff out the content type. See http.serveContent.
  1027  				var buf [512]byte
  1028  				n, _ := io.ReadFull(f, buf[:])
  1029  				contentType = http.DetectContentType(buf[:n])
  1030  				_, err = f.Seek(0, io.SeekStart) // rewind to output whole file
  1031  				if err != nil {
  1032  					http.Error(w, err.Error(), http.StatusInternalServerError)
  1033  					return
  1034  				}
  1035  			}
  1036  			if contentType != "" {
  1037  				w.Header().Set("Content-Type", contentType)
  1038  			} // else don't set it (plain)
  1039  		}
  1040  
  1041  		sendSize := stat.Size()
  1042  		w.Header().Set("Content-Length", strconv.FormatInt(sendSize, 10))
  1043  
  1044  		// TODO: Set Last-Modified for the embedded files.
  1045  		// if modTime != nil {
  1046  		// 	w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat))
  1047  		// }
  1048  
  1049  		_, err = io.CopyN(w, f, sendSize)
  1050  		if err != nil {
  1051  			log.Errorf("Writing response for path %q failed: %v", r.URL.Path, err)
  1052  			// Too late to write to header with error code.
  1053  		}
  1054  	}
  1055  
  1056  	// For the chi.Mux, make sure a path that ends in "/" and append a "*".
  1057  	muxRoot := pathPrefix
  1058  	if pathPrefix != "/" && pathPrefix[len(pathPrefix)-1] != '/' {
  1059  		r.Get(pathPrefix, http.RedirectHandler(pathPrefix+"/", 301).ServeHTTP)
  1060  		muxRoot += "/"
  1061  	}
  1062  	muxRoot += "*"
  1063  
  1064  	// Mount the http.HandlerFunc on the pathPrefix.
  1065  	r.Get(muxRoot, hf)
  1066  }
  1067  
  1068  // writeJSON marshals the provided interface and writes the bytes to the
  1069  // ResponseWriter. The response code is assumed to be StatusOK.
  1070  func writeJSON(w http.ResponseWriter, thing any) {
  1071  	writeJSONWithStatus(w, thing, http.StatusOK)
  1072  }
  1073  
  1074  // writeJSON writes marshals the provided interface and writes the bytes to the
  1075  // ResponseWriter with the specified response code.
  1076  func writeJSONWithStatus(w http.ResponseWriter, thing any, code int) {
  1077  	w.Header().Set("Content-Type", "application/json; charset=utf-8")
  1078  	b, err := json.Marshal(thing)
  1079  	if err != nil {
  1080  		w.WriteHeader(http.StatusInternalServerError)
  1081  		log.Errorf("JSON encode error: %v", err)
  1082  		return
  1083  	}
  1084  	w.WriteHeader(code)
  1085  	_, err = w.Write(append(b, byte('\n')))
  1086  	if err != nil {
  1087  		log.Errorf("Write error: %v", err)
  1088  	}
  1089  }
  1090  
  1091  func folderExists(fp string) bool {
  1092  	stat, err := os.Stat(fp)
  1093  	return err == nil && stat.IsDir()
  1094  }
  1095  
  1096  // chiLogger is an adaptor around dex.Logger that satisfies
  1097  // chi/middleware.LoggerInterface for chi's DefaultLogFormatter.
  1098  type chiLogger struct {
  1099  	dex.Logger
  1100  }
  1101  
  1102  func (l *chiLogger) Print(v ...any) {
  1103  	l.Trace(v...)
  1104  }