Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add manual ticket search feature. #322

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions webapi/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ func (s *Server) adminPage(c *gin.Context) {
func (s *Server) ticketSearch(c *gin.Context) {
hash := c.PostForm("hash")

// Before hitting the db, ensure this is a valid ticket hash. Ignore bool.
if err := validateTicketHash(hash); err != nil {
s.log.Errorf("ticketSearch: Invalid ticket hash (ticketHash=%s): %v", hash, err)
c.String(http.StatusBadRequest, "invalid ticket hash")
return
}

ticket, found, err := s.db.GetTicketByHash(hash)
if err != nil {
s.log.Errorf("db.GetTicketByHash error (ticketHash=%s): %v", hash, err)
Expand Down
115 changes: 115 additions & 0 deletions webapi/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ package webapi

import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/decred/vspd/rpc"
"github.com/gin-gonic/gin"
Expand All @@ -18,6 +21,10 @@ import (
"github.com/jrick/wsrpc/v2"
)

// TicketSearchMessageFmt is the format for the message to be signed
// in order to search for a ticket using the vspd frontend.
const TicketSearchMessageFmt = "I want to check vspd ticket status for ticket %s at vsp with pubkey %s on window %d."

// withSession middleware adds a gorilla session to the request context for
// downstream handlers to make use of. Sessions are used by admin pages to
// maintain authentication status.
Expand Down Expand Up @@ -349,3 +356,111 @@ func (s *Server) vspAuth(c *gin.Context) {
c.Set(knownTicketKey, ticketFound)
c.Set(commitmentAddressKey, commitmentAddress)
}

// ticketSearchAuth middleware reads the request form body and extracts the
// ticket hash and signature from the base64 string provided. The commitment
// address for the ticket is retrieved from the database if it is known, or it
// is retrieved from the chain if not. The middleware errors out if required
// information is not provided or the signature does not contain a message
// signed with the commitment address. Ticket information is added to the
// request context for downstream handlers to use.
func (s *Server) ticketSearchAuth(c *gin.Context) {
funcName := "ticketSearchAuth"

encodedString := c.PostForm("encoded")

// Get information added to context.
dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC)
dcrdErr := c.MustGet(dcrdErrorKey)
if dcrdErr != nil {
s.log.Errorf("%s: Could not get dcrd client: %v", funcName, dcrdErr.(error))
c.Set(errorKey, errInternalError)
return
}

currentBlockHeader, err := dcrdClient.GetBestBlockHeader()
if err != nil {
s.log.Errorf("%s: Error getting best block header : %v", funcName, err)
c.Set(errorKey, errInternalError)
// Average blocks per day for the current network.
blocksPerDay := (24 * time.Hour) / s.cfg.NetParams.TargetTimePerBlock
blockWindow := int(currentBlockHeader.Height) / int(blocksPerDay)

decodedByte, err := base64.StdEncoding.DecodeString(encodedString)
if err != nil {
s.log.Errorf("%s: Decoding form data error : %v", funcName, err)
c.Set(errorKey, errBadRequest)
return
}

data := strings.Split(string(decodedByte), ":")
if len(data) != 2 {
c.Set(errorKey, errBadRequest)
return
}

ticketHash := data[0]
signature := data[1]
vspPublicKey := s.cache.data.PubKey
messageSigned := fmt.Sprintf(TicketSearchMessageFmt, ticketHash, vspPublicKey, blockWindow)

// Before hitting the db or any RPC, ensure this is a valid ticket hash.
err = validateTicketHash(ticketHash)
if err != nil {
s.log.Errorf("%s: Invalid ticket (clientIP=%s): %v", funcName, c.ClientIP(), err)
c.Set(errorKey, errInvalidTicket)
return
}

// Check if this ticket already appears in the database.
ticket, ticketFound, err := s.db.GetTicketByHash(ticketHash)
if err != nil {
s.log.Errorf("%s: db.GetTicketByHash error (ticketHash=%s): %v", funcName, ticketHash, err)
c.Set(errorKey, errInternalError)
return
}

if !ticketFound {
s.log.Warnf("%s: Unknown ticket (clientIP=%s)", funcName, c.ClientIP())
c.Set(errorKey, errUnknownTicket)
return
}

// If the ticket was found in the database, we already know its
// commitment address. Otherwise we need to get it from the chain.
var commitmentAddress string
if ticketFound {
commitmentAddress = ticket.CommitmentAddress
} else {
commitmentAddress, err = getCommitmentAddress(ticketHash, dcrdClient, s.cfg.NetParams)
if err != nil {
s.log.Errorf("%s: Failed to get commitment address (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), ticketHash, err)

var apiErr *apiError
if errors.Is(err, apiErr) {
c.Set(errorKey, errInvalidTicket)
} else {
c.Set(errorKey, errInternalError)
}

return
}
}

// Validate request signature to ensure ticket ownership.
err = validateSignature(ticketHash, commitmentAddress, signature, messageSigned, s.db, s.cfg.NetParams)
if err != nil {
s.log.Errorf("%s: Couldn't validate signature (clientIP=%s, ticketHash=%s): %v",
funcName, c.ClientIP(), ticketHash, err)
c.Set(errorKey, errBadSignature)
return
}

// Add ticket information to context so downstream handlers don't need
// to access the db for it.
c.Set(ticketKey, ticket)
c.Set(knownTicketKey, ticketFound)
c.Set(errorKey, nil)
}
}
2 changes: 1 addition & 1 deletion webapi/templates/homepage.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ <h1>VSP Overview</h1>

<p class="pt-1 pb-2">A Voting Service Provider (VSP) maintains a pool of always-online voting wallets,
and allows Decred ticket holders to use these wallets to vote their tickets in exchange for a small fee.
VSPs are completely non-custodial - they never hold, manage, or have access to any user funds.
VSPs are completely non-custodial - they never hold, manage, or have access to any user funds. <a href="/ticket" target="_blank" rel="noopener noreferrer">Click here to search tickets</a>. <br>
Visit <a href="https://docs.decred.org/proof-of-stake/overview/" target="_blank" rel="noopener noreferrer">docs.decred.org</a>
to find out more about VSPs, tickets, and voting.
</p>
Expand Down
39 changes: 39 additions & 0 deletions webapi/templates/ticket.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{{ template "header" . }}

<div class="vsp-overview pt-4 pb-3 mb-3">
<div class="container">

<div class="d-flex flex-wrap">
<h1>Ticket Search</h1>
</div>
</div>
</div>

<div class="container">
<div class="row">
<div class="col-12 pt-2 pb-4">
<div class="block__content">
<section>
<form action="/ticket" method="post">
<p class="my-1 orange" style="visibility:{{ if .Error }}visible{{ else }}hidden{{ end }};">
{{.Error}}</p>

<div class="mb-3 col col-lg-8 pl-0">
<label for="encodedInput" class="form-label">Encoded Base64 String</label>
<input type="text" name="encoded" size="64" class="form-control shadow-none" minlength="64"
maxlength="500" id="encodedInput" required placeholder="Encoded string" autocomplete="off" value="">
</div>
<button class="btn btn-primary d-block my-2" type="submit">Search</button>
</form>

{{ with .SearchResult }}
{{ template "ticket-search-result" . }}
{{ end }}
</section>
</div>
</div>
</div>
</div>

</div>
{{ template "footer" . }}
71 changes: 71 additions & 0 deletions webapi/ticket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2022 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package webapi

import (
"net/http"

"github.com/decred/vspd/database"
"github.com/gin-gonic/gin"
)

// ticketPage is the handler for "GET /ticket"
func (s *Server) ticketPage(c *gin.Context) {
c.HTML(http.StatusOK, "ticket.html", gin.H{
"WebApiCache": s.cache.getData(),
"WebApiCfg": s.cfg,
})
}

// ticketErrPage returns error message to the ticket page.
func (s *Server) ticketErrPage(c *gin.Context, status int, message string) {
c.HTML(status, "ticket.html", gin.H{
"WebApiCache": s.cache.getData(),
"WebApiCfg": s.cfg,
"Error": message,
})

}

// manualTicketSearch is the handler for "POST /ticket".
func (s *Server) manualTicketSearch(c *gin.Context) {
// Get values which have been added to context by middleware.
err := c.MustGet(errorKey)
if err != nil {
apiErr := err.(apiError)
s.ticketErrPage(c, apiErr.httpStatus(), apiErr.String())
return
}

ticket := c.MustGet(ticketKey).(database.Ticket)
knownTicket := c.MustGet(knownTicketKey).(bool)

voteChanges, err := s.db.GetVoteChanges(ticket.Hash)
if err != nil {
s.log.Errorf("db.GetVoteChanges error (ticket=%s): %v", ticket.Hash, err)
s.ticketErrPage(c, http.StatusBadRequest, "Error getting vote changes from database")
return
}

altSignAddrData, err := s.db.AltSignAddrData(ticket.Hash)
if err != nil {
s.log.Errorf("db.AltSignAddrData error (ticket=%s): %v", ticket.Hash, err)
s.ticketErrPage(c, http.StatusBadRequest, "Error getting alternate signature from database")
return
}

c.HTML(http.StatusOK, "ticket.html", gin.H{
"SearchResult": searchResult{
Hash: ticket.Hash,
Found: knownTicket,
Ticket: ticket,
AltSignAddrData: altSignAddrData,
VoteChanges: voteChanges,
MaxVoteChanges: s.cfg.MaxVoteChangeRecords,
},
"WebApiCache": s.cache.getData(),
"WebApiCfg": s.cfg,
})
}
3 changes: 3 additions & 0 deletions webapi/webapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const (
ticketKey = "Ticket"
knownTicketKey = "KnownTicket"
commitmentAddressKey = "CommitmentAddress"
errorKey = "Error"
)

type Server struct {
Expand Down Expand Up @@ -244,6 +245,8 @@ func (s *Server) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W
// Website routes.

router.GET("", s.homepage)
router.GET("/ticket", s.ticketPage)
router.POST("/ticket", s.withDcrdClient(dcrd), s.ticketSearchAuth, s.manualTicketSearch)

login := router.Group("/admin").Use(
s.withSession(cookieStore),
Expand Down