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