From 99dc97d6a3e014dec3ab946109daaa8e429a4bbb Mon Sep 17 00:00:00 2001 From: jholdstock Date: Mon, 18 Sep 2023 17:52:19 +0100 Subject: [PATCH] webapi: Abort requests if web cache not ready. A new middleware aborts any requests which require the data cache if the cache is not yet initialized. An explicit error is returned so the admin will be immediately aware what has gone wrong. Previously, webpages were rendered and JSON responses were sent with zero values, and with no indication of anything being wrong. This was sometimes difficult to notice, and even when noticed the cause was not immediately apparent. --- internal/webapi/admin.go | 12 +++++++++--- internal/webapi/cache.go | 12 ++++++++++++ internal/webapi/homepage.go | 4 +++- internal/webapi/middleware.go | 18 +++++++++++++++++- internal/webapi/vspinfo.go | 3 ++- internal/webapi/webapi.go | 18 ++++++++++++------ 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/internal/webapi/admin.go b/internal/webapi/admin.go index 6fe29a2..fbf2584 100644 --- a/internal/webapi/admin.go +++ b/internal/webapi/admin.go @@ -144,8 +144,10 @@ func (w *WebAPI) statusJSON(c *gin.Context) { // adminPage is the handler for "GET /admin". func (w *WebAPI) adminPage(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) + c.HTML(http.StatusOK, "admin.html", gin.H{ - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, "WalletStatus": w.walletStatus(c), "DcrdStatus": w.dcrdStatus(c), @@ -155,6 +157,8 @@ func (w *WebAPI) adminPage(c *gin.Context) { // ticketSearch is the handler for "POST /admin/ticket". The hash param will be // used to retrieve a ticket from the database. func (w *WebAPI) ticketSearch(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) + hash := c.PostForm("hash") ticket, found, err := w.db.GetTicketByHash(hash) @@ -218,7 +222,7 @@ func (w *WebAPI) ticketSearch(c *gin.Context) { VoteChanges: voteChanges, MaxVoteChanges: w.cfg.MaxVoteChangeRecords, }, - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, "WalletStatus": w.walletStatus(c), "DcrdStatus": w.dcrdStatus(c), @@ -228,12 +232,14 @@ func (w *WebAPI) ticketSearch(c *gin.Context) { // adminLogin is the handler for "POST /admin". If a valid password is provided, // the current session will be authenticated as an admin. func (w *WebAPI) adminLogin(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) + password := c.PostForm("password") if password != w.cfg.AdminPass { w.log.Warnf("Failed login attempt from %s", c.ClientIP()) c.HTML(http.StatusUnauthorized, "login.html", gin.H{ - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, "FailedLoginMsg": "Incorrect password", }) diff --git a/internal/webapi/cache.go b/internal/webapi/cache.go index 19dcd79..b36418a 100644 --- a/internal/webapi/cache.go +++ b/internal/webapi/cache.go @@ -30,6 +30,10 @@ type cache struct { } type cacheData struct { + // Initialized is set true after all of the below values have been set for + // the first time. + Initialized bool + UpdateTime string PubKey string DatabaseSize string @@ -45,6 +49,13 @@ type cacheData struct { MissedProportion float32 } +func (c *cache) initialized() bool { + c.mtx.RLock() + defer c.mtx.RUnlock() + + return c.data.Initialized +} + func (c *cache) getData() cacheData { c.mtx.RLock() defer c.mtx.RUnlock() @@ -106,6 +117,7 @@ func (c *cache) update() error { c.mtx.Lock() defer c.mtx.Unlock() + c.data.Initialized = true c.data.UpdateTime = dateTime(time.Now().Unix()) c.data.DatabaseSize = humanize.Bytes(dbSize) c.data.Voting = voting diff --git a/internal/webapi/homepage.go b/internal/webapi/homepage.go index e8c26d0..a7fdd3d 100644 --- a/internal/webapi/homepage.go +++ b/internal/webapi/homepage.go @@ -11,8 +11,10 @@ import ( ) func (w *WebAPI) homepage(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) + c.HTML(http.StatusOK, "homepage.html", gin.H{ - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, }) } diff --git a/internal/webapi/middleware.go b/internal/webapi/middleware.go index 14b08bc..304236b 100644 --- a/internal/webapi/middleware.go +++ b/internal/webapi/middleware.go @@ -93,15 +93,31 @@ func (w *WebAPI) withSession(store *sessions.CookieStore) gin.HandlerFunc { } } +// requireWebCache will only allow the request to proceed if the web API cache +// has been initialized with data, otherwise it will return a 500 Internal +// Server Error. +func (w *WebAPI) requireWebCache(c *gin.Context) { + if !w.cache.initialized() { + const str = "Cache is not yet initialized" + w.log.Errorf(str) + c.String(http.StatusInternalServerError, str) + c.Abort() + return + } + + c.Set(cacheKey, w.cache.getData()) +} + // requireAdmin will only allow the request to proceed if the current session is // authenticated as an admin, otherwise it will render the login template. func (w *WebAPI) requireAdmin(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) session := c.MustGet(sessionKey).(*sessions.Session) admin := session.Values["admin"] if admin == nil { c.HTML(http.StatusUnauthorized, "login.html", gin.H{ - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, }) c.Abort() diff --git a/internal/webapi/vspinfo.go b/internal/webapi/vspinfo.go index 4f4568d..6552a25 100644 --- a/internal/webapi/vspinfo.go +++ b/internal/webapi/vspinfo.go @@ -14,7 +14,8 @@ import ( // vspInfo is the handler for "GET /api/v3/vspinfo". func (w *WebAPI) vspInfo(c *gin.Context) { - cachedStats := w.cache.getData() + cachedStats := c.MustGet(cacheKey).(cacheData) + w.sendJSONResponse(types.VspInfoResponse{ APIVersions: []int64{3}, Timestamp: time.Now().Unix(), diff --git a/internal/webapi/webapi.go b/internal/webapi/webapi.go index 9499759..a8f27d4 100644 --- a/internal/webapi/webapi.go +++ b/internal/webapi/webapi.go @@ -57,6 +57,7 @@ const ( dcrdKey = "DcrdClient" dcrdHostKey = "DcrdHostname" dcrdErrorKey = "DcrdClientErr" + cacheKey = "Cache" walletsKey = "WalletClients" failedWalletsKey = "FailedWalletClients" requestBytesKey = "RequestBytes" @@ -240,7 +241,7 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W // API routes. api := router.Group("/api/v3") - api.GET("/vspinfo", w.vspInfo) + api.GET("/vspinfo", w.requireWebCache, w.vspInfo) api.POST("/setaltsignaddr", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.setAltSignAddr) api.POST("/feeaddress", w.vspMustBeOpen, w.withDcrdClient(dcrd), w.broadcastTicket, w.vspAuth, w.feeAddress) api.POST("/ticketstatus", w.withDcrdClient(dcrd), w.vspAuth, w.ticketStatus) @@ -249,7 +250,7 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W // Website routes. - router.GET("", w.homepage) + router.GET("", w.requireWebCache, w.homepage) login := router.Group("/admin").Use( w.withSession(cookieStore), @@ -257,18 +258,23 @@ func (w *WebAPI) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W // Limit login attempts to 3 per second. loginRateLmiter := rateLimit(3, func(c *gin.Context) { + cacheData := c.MustGet(cacheKey).(cacheData) + w.log.Warnf("Login rate limit exceeded by %s", c.ClientIP()) c.HTML(http.StatusTooManyRequests, "login.html", gin.H{ - "WebApiCache": w.cache.getData(), + "WebApiCache": cacheData, "WebApiCfg": w.cfg, "FailedLoginMsg": "Rate limit exceeded", }) }) - login.POST("", loginRateLmiter, w.adminLogin) + login.POST("", w.requireWebCache, loginRateLmiter, w.adminLogin) admin := router.Group("/admin").Use( - w.withWalletClients(wallets), w.withSession(cookieStore), w.requireAdmin, - ) + w.requireWebCache, + w.withWalletClients(wallets), + w.withSession(cookieStore), + w.requireAdmin) + admin.GET("", w.withDcrdClient(dcrd), w.adminPage) admin.POST("/ticket", w.withDcrdClient(dcrd), w.ticketSearch) admin.GET("/backup", w.downloadDatabaseBackup)