Store records of vote choice changes
This commit is contained in:
parent
d0c3abf258
commit
825a717ca7
@ -23,7 +23,8 @@ import (
|
|||||||
// VspDatabase wraps an instance of bolt.DB and provides VSP specific
|
// VspDatabase wraps an instance of bolt.DB and provides VSP specific
|
||||||
// convenience functions.
|
// convenience functions.
|
||||||
type VspDatabase struct {
|
type VspDatabase struct {
|
||||||
db *bolt.DB
|
db *bolt.DB
|
||||||
|
maxVoteChangeRecords int
|
||||||
|
|
||||||
ticketsMtx sync.RWMutex
|
ticketsMtx sync.RWMutex
|
||||||
}
|
}
|
||||||
@ -35,6 +36,8 @@ var (
|
|||||||
vspBktK = []byte("vspbkt")
|
vspBktK = []byte("vspbkt")
|
||||||
// ticketbkt stores all tickets known by this VSP.
|
// ticketbkt stores all tickets known by this VSP.
|
||||||
ticketBktK = []byte("ticketbkt")
|
ticketBktK = []byte("ticketbkt")
|
||||||
|
// votechangebkt stores records of web requests which update vote choices.
|
||||||
|
voteChangeBktK = []byte("votechangebkt")
|
||||||
// version is the current database version.
|
// version is the current database version.
|
||||||
versionK = []byte("version")
|
versionK = []byte("version")
|
||||||
// feeXPub is the extended public key used for collecting VSP fees.
|
// feeXPub is the extended public key used for collecting VSP fees.
|
||||||
@ -150,6 +153,12 @@ func CreateNew(dbFile, feeXPub string) error {
|
|||||||
return fmt.Errorf("failed to create %s bucket: %v", string(ticketBktK), err)
|
return fmt.Errorf("failed to create %s bucket: %v", string(ticketBktK), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create vote change bucket.
|
||||||
|
_, err = vspBkt.CreateBucket(voteChangeBktK)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s bucket: %v", string(voteChangeBktK), err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -164,7 +173,7 @@ func CreateNew(dbFile, feeXPub string) error {
|
|||||||
|
|
||||||
// Open initializes and returns an open database. An error is returned if no
|
// Open initializes and returns an open database. An error is returned if no
|
||||||
// database file is found at the provided path.
|
// database file is found at the provided path.
|
||||||
func Open(ctx context.Context, shutdownWg *sync.WaitGroup, dbFile string, backupInterval time.Duration) (*VspDatabase, error) {
|
func Open(ctx context.Context, shutdownWg *sync.WaitGroup, dbFile string, backupInterval time.Duration, maxVoteChangeRecords int) (*VspDatabase, error) {
|
||||||
|
|
||||||
// Error if db file does not exist. This is needed because bolt.Open will
|
// Error if db file does not exist. This is needed because bolt.Open will
|
||||||
// silently create a new empty database if the file does not exist. A new
|
// silently create a new empty database if the file does not exist. A new
|
||||||
@ -200,7 +209,7 @@ func Open(ctx context.Context, shutdownWg *sync.WaitGroup, dbFile string, backup
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return &VspDatabase{db: db}, nil
|
return &VspDatabase{db: db, maxVoteChangeRecords: maxVoteChangeRecords}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close will close the database and then make a copy of the database to the
|
// Close will close the database and then make a copy of the database to the
|
||||||
|
|||||||
@ -13,9 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
testDb = "test.db"
|
testDb = "test.db"
|
||||||
backupDb = "test.db-backup"
|
backupDb = "test.db-backup"
|
||||||
db *VspDatabase
|
db *VspDatabase
|
||||||
|
maxVoteChangeRecords = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDatabase runs all database tests.
|
// TestDatabase runs all database tests.
|
||||||
@ -26,13 +27,14 @@ func TestDatabase(t *testing.T) {
|
|||||||
|
|
||||||
// All sub-tests to run.
|
// All sub-tests to run.
|
||||||
tests := map[string]func(*testing.T){
|
tests := map[string]func(*testing.T){
|
||||||
"testInsertNewTicket": testInsertNewTicket,
|
"testInsertNewTicket": testInsertNewTicket,
|
||||||
"testGetTicketByHash": testGetTicketByHash,
|
"testGetTicketByHash": testGetTicketByHash,
|
||||||
"testUpdateTicket": testUpdateTicket,
|
"testUpdateTicket": testUpdateTicket,
|
||||||
"testTicketFeeExpired": testTicketFeeExpired,
|
"testTicketFeeExpired": testTicketFeeExpired,
|
||||||
"testFilterTickets": testFilterTickets,
|
"testFilterTickets": testFilterTickets,
|
||||||
"testAddressIndex": testAddressIndex,
|
"testAddressIndex": testAddressIndex,
|
||||||
"testDeleteTicket": testDeleteTicket,
|
"testDeleteTicket": testDeleteTicket,
|
||||||
|
"testVoteChangeRecords": testVoteChangeRecords,
|
||||||
}
|
}
|
||||||
|
|
||||||
for testName, test := range tests {
|
for testName, test := range tests {
|
||||||
@ -44,7 +46,7 @@ func TestDatabase(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error creating test database: %v", err)
|
t.Fatalf("error creating test database: %v", err)
|
||||||
}
|
}
|
||||||
db, err = Open(ctx, &wg, testDb, time.Hour)
|
db, err = Open(ctx, &wg, testDb, time.Hour, maxVoteChangeRecords)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error opening test database: %v", err)
|
t.Fatalf("error opening test database: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,6 +139,7 @@ func (vdb *VspDatabase) DeleteTicket(ticket Ticket) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vdb *VspDatabase) UpdateTicket(ticket Ticket) error {
|
func (vdb *VspDatabase) UpdateTicket(ticket Ticket) error {
|
||||||
defer vdb.ticketsMtx.Unlock()
|
defer vdb.ticketsMtx.Unlock()
|
||||||
vdb.ticketsMtx.Lock()
|
vdb.ticketsMtx.Lock()
|
||||||
|
|||||||
131
database/votechange.go
Normal file
131
database/votechange.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2020 The Decred developers
|
||||||
|
// Use of this source code is governed by an ISC
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoteChangeRecord is serialized to json and stored in bbolt db. The json keys
|
||||||
|
// are deliberately kept short because they are duplicated many times in the db.
|
||||||
|
type VoteChangeRecord struct {
|
||||||
|
Request string `json:"req"`
|
||||||
|
RequestSignature string `json:"reqs"`
|
||||||
|
Response string `json:"rsp"`
|
||||||
|
ResponseSignature string `json:"rsps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveVoteChange will insert the provided vote change record into the database,
|
||||||
|
// and if this breaches the maximum amount of allowed records, delete the oldest
|
||||||
|
// one which is currently stored.
|
||||||
|
func (vdb *VspDatabase) SaveVoteChange(ticketHash string, record VoteChangeRecord) error {
|
||||||
|
|
||||||
|
return vdb.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
// Create or get a bucket for this ticket.
|
||||||
|
bkt, err := tx.Bucket(vspBktK).Bucket(voteChangeBktK).
|
||||||
|
CreateBucketIfNotExists([]byte(ticketHash))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create vote change bucket (ticketHash=%s): %v",
|
||||||
|
ticketHash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize record for storage in the database.
|
||||||
|
recordBytes, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not marshal vote change record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records are stored using a serially increasing integer as the key.
|
||||||
|
|
||||||
|
// Loop through the bucket to count the records, as well as finding the
|
||||||
|
// most recent and the oldest record.
|
||||||
|
var count int
|
||||||
|
highest := uint32(0)
|
||||||
|
lowest := uint32(math.MaxUint32)
|
||||||
|
err = bkt.ForEach(func(k, v []byte) error {
|
||||||
|
count++
|
||||||
|
key := binary.LittleEndian.Uint32(k)
|
||||||
|
if key > highest {
|
||||||
|
highest = key
|
||||||
|
}
|
||||||
|
if key < lowest {
|
||||||
|
lowest = key
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error iterating over vote change bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If bucket is at (or over) the limit of max allowed records, remove
|
||||||
|
// the oldest one.
|
||||||
|
if count >= vdb.maxVoteChangeRecords {
|
||||||
|
keyBytes := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(keyBytes, lowest)
|
||||||
|
err = bkt.Delete(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete old vote change record: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert record with index 0 if the bucket is currently empty,
|
||||||
|
// otherwise use most recent + 1.
|
||||||
|
var newKey uint32
|
||||||
|
if count > 0 {
|
||||||
|
newKey = highest + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(keyBytes, newKey)
|
||||||
|
|
||||||
|
// Insert record.
|
||||||
|
err = bkt.Put(keyBytes, recordBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not store vote change record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVoteChanges retrieves all of the stored vote change records for the
|
||||||
|
// provided ticket hash.
|
||||||
|
func (vdb *VspDatabase) GetVoteChanges(ticketHash string) (map[uint32]VoteChangeRecord, error) {
|
||||||
|
|
||||||
|
records := make(map[uint32]VoteChangeRecord)
|
||||||
|
|
||||||
|
err := vdb.db.View(func(tx *bolt.Tx) error {
|
||||||
|
bkt := tx.Bucket(vspBktK).Bucket(voteChangeBktK).
|
||||||
|
Bucket([]byte(ticketHash))
|
||||||
|
|
||||||
|
if bkt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bkt.ForEach(func(k, v []byte) error {
|
||||||
|
var record VoteChangeRecord
|
||||||
|
err := json.Unmarshal(v, &record)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not unmarshal vote change record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records[binary.LittleEndian.Uint32(k)] = record
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error iterating over vote change bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
66
database/votechange_test.go
Normal file
66
database/votechange_test.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2020 The Decred developers
|
||||||
|
// Use of this source code is governed by an ISC
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exampleRecord() VoteChangeRecord {
|
||||||
|
return VoteChangeRecord{
|
||||||
|
Request: "Request",
|
||||||
|
RequestSignature: "RequestSignature",
|
||||||
|
Response: "Response",
|
||||||
|
ResponseSignature: "ResponseSignature",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVoteChangeRecords(t *testing.T) {
|
||||||
|
hash := "MyHash"
|
||||||
|
record := exampleRecord()
|
||||||
|
|
||||||
|
// Insert a record into the database.
|
||||||
|
err := db.SaveVoteChange(hash, record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error storing vote change record in database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve record and check values.
|
||||||
|
retrieved, err := db.GetVoteChanges(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error retrieving vote change records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(retrieved) != 1 ||
|
||||||
|
retrieved[0].Request != record.Request ||
|
||||||
|
retrieved[0].RequestSignature != record.RequestSignature ||
|
||||||
|
retrieved[0].Response != record.Response ||
|
||||||
|
retrieved[0].ResponseSignature != record.ResponseSignature {
|
||||||
|
t.Fatal("retrieved record didnt match expected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert some more records, giving us one greater than the limit.
|
||||||
|
for i := 0; i < maxVoteChangeRecords; i++ {
|
||||||
|
err = db.SaveVoteChange(hash, record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error storing vote change record in database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve records.
|
||||||
|
retrieved, err = db.GetVoteChanges(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error retrieving vote change records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest record should have been deleted.
|
||||||
|
if len(retrieved) != maxVoteChangeRecords {
|
||||||
|
t.Fatalf("vote change record limit breached")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := retrieved[0]; ok {
|
||||||
|
t.Fatalf("oldest vote change record should have been deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
23
vspd.go
23
vspd.go
@ -19,6 +19,12 @@ import (
|
|||||||
"github.com/decred/vspd/webapi"
|
"github.com/decred/vspd/webapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxVoteChangeRecords defines how many vote change records will be stored for
|
||||||
|
// each ticket. The limit is in place to mitigate DoS attacks on server storage
|
||||||
|
// space. When storing a new record breaches this limit, the oldest record in
|
||||||
|
// the database is deleted.
|
||||||
|
const maxVoteChangeRecords = 10
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create a context that is cancelled when a shutdown request is received
|
// Create a context that is cancelled when a shutdown request is received
|
||||||
// through an interrupt signal.
|
// through an interrupt signal.
|
||||||
@ -59,7 +65,7 @@ func run(ctx context.Context) error {
|
|||||||
defer log.Info("Shutdown complete")
|
defer log.Info("Shutdown complete")
|
||||||
|
|
||||||
// Open database.
|
// Open database.
|
||||||
db, err := database.Open(ctx, &shutdownWg, cfg.dbPath, cfg.BackupInterval)
|
db, err := database.Open(ctx, &shutdownWg, cfg.dbPath, cfg.BackupInterval, maxVoteChangeRecords)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Database error: %v", err)
|
log.Errorf("Database error: %v", err)
|
||||||
requestShutdown()
|
requestShutdown()
|
||||||
@ -79,13 +85,14 @@ func run(ctx context.Context) error {
|
|||||||
|
|
||||||
// Create and start webapi server.
|
// Create and start webapi server.
|
||||||
apiCfg := webapi.Config{
|
apiCfg := webapi.Config{
|
||||||
VSPFee: cfg.VSPFee,
|
VSPFee: cfg.VSPFee,
|
||||||
NetParams: cfg.netParams.Params,
|
NetParams: cfg.netParams.Params,
|
||||||
SupportEmail: cfg.SupportEmail,
|
SupportEmail: cfg.SupportEmail,
|
||||||
VspClosed: cfg.VspClosed,
|
VspClosed: cfg.VspClosed,
|
||||||
AdminPass: cfg.AdminPass,
|
AdminPass: cfg.AdminPass,
|
||||||
Debug: cfg.WebServerDebug,
|
Debug: cfg.WebServerDebug,
|
||||||
Designation: cfg.Designation,
|
Designation: cfg.Designation,
|
||||||
|
MaxVoteChangeRecords: maxVoteChangeRecords,
|
||||||
}
|
}
|
||||||
err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db,
|
err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db,
|
||||||
dcrd, wallets, apiCfg)
|
dcrd, wallets, apiCfg)
|
||||||
|
|||||||
@ -100,16 +100,25 @@ func ticketSearch(c *gin.Context) {
|
|||||||
|
|
||||||
ticket, found, err := db.GetTicketByHash(hash)
|
ticket, found, err := db.GetTicketByHash(hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("db.GetTicketByHash error: %v", err)
|
log.Errorf("db.GetTicketByHash error (ticketHash=%s): %v", hash, err)
|
||||||
c.String(http.StatusInternalServerError, "Error getting ticket from db")
|
c.String(http.StatusInternalServerError, "Error getting ticket from db")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
voteChanges, err := db.GetVoteChanges(hash)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("db.GetVoteChanges error (ticketHash=%s): %v", hash, err)
|
||||||
|
c.String(http.StatusInternalServerError, "Error getting vote changes from db")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "admin.html", gin.H{
|
c.HTML(http.StatusOK, "admin.html", gin.H{
|
||||||
"SearchResult": gin.H{
|
"SearchResult": gin.H{
|
||||||
"Hash": hash,
|
"Hash": hash,
|
||||||
"Found": found,
|
"Found": found,
|
||||||
"Ticket": ticket,
|
"Ticket": ticket,
|
||||||
|
"VoteChanges": voteChanges,
|
||||||
|
"MaxVoteChanges": cfg.MaxVoteChangeRecords,
|
||||||
},
|
},
|
||||||
"VspStats": getVSPStats(),
|
"VspStats": getVSPStats(),
|
||||||
"WalletStatus": walletStatus(c),
|
"WalletStatus": walletStatus(c),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/decred/vspd/database"
|
"github.com/decred/vspd/database"
|
||||||
"github.com/decred/vspd/rpc"
|
"github.com/decred/vspd/rpc"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addrMtx protects getNewFeeAddress.
|
// addrMtx protects getNewFeeAddress.
|
||||||
@ -71,6 +72,7 @@ func feeAddress(c *gin.Context) {
|
|||||||
knownTicket := c.MustGet("KnownTicket").(bool)
|
knownTicket := c.MustGet("KnownTicket").(bool)
|
||||||
commitmentAddress := c.MustGet("CommitmentAddress").(string)
|
commitmentAddress := c.MustGet("CommitmentAddress").(string)
|
||||||
dcrdClient := c.MustGet("DcrdClient").(*rpc.DcrdRPC)
|
dcrdClient := c.MustGet("DcrdClient").(*rpc.DcrdRPC)
|
||||||
|
reqBytes := c.MustGet("RequestBytes").([]byte)
|
||||||
|
|
||||||
if cfg.VspClosed {
|
if cfg.VspClosed {
|
||||||
sendError(errVspClosed, c)
|
sendError(errVspClosed, c)
|
||||||
@ -78,7 +80,7 @@ func feeAddress(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var request feeAddressRequest
|
var request feeAddressRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
|
||||||
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
||||||
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -203,16 +203,17 @@ func vspAuth() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
const funcName = "vspAuth"
|
const funcName = "vspAuth"
|
||||||
|
|
||||||
// Read request bytes and then replace the request reader for
|
// Read request bytes.
|
||||||
// downstream handlers to use.
|
|
||||||
reqBytes, err := ioutil.ReadAll(c.Request.Body)
|
reqBytes, err := ioutil.ReadAll(c.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("%s: Error reading request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
log.Warnf("%s: Error reading request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
||||||
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Request.Body.Close()
|
|
||||||
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(reqBytes))
|
// Add request bytes to request context for downstream handlers to reuse.
|
||||||
|
// Necessary because the request body reader can only be used once.
|
||||||
|
c.Set("RequestBytes", reqBytes)
|
||||||
|
|
||||||
// Parse request and ensure there is a ticket hash included.
|
// Parse request and ensure there is a ticket hash included.
|
||||||
var request ticketHashRequest
|
var request ticketHashRequest
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/decred/vspd/database"
|
"github.com/decred/vspd/database"
|
||||||
"github.com/decred/vspd/rpc"
|
"github.com/decred/vspd/rpc"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
)
|
)
|
||||||
|
|
||||||
// payFee is the handler for "POST /api/v3/payfee".
|
// payFee is the handler for "POST /api/v3/payfee".
|
||||||
@ -24,6 +25,7 @@ func payFee(c *gin.Context) {
|
|||||||
ticket := c.MustGet("Ticket").(database.Ticket)
|
ticket := c.MustGet("Ticket").(database.Ticket)
|
||||||
knownTicket := c.MustGet("KnownTicket").(bool)
|
knownTicket := c.MustGet("KnownTicket").(bool)
|
||||||
dcrdClient := c.MustGet("DcrdClient").(*rpc.DcrdRPC)
|
dcrdClient := c.MustGet("DcrdClient").(*rpc.DcrdRPC)
|
||||||
|
reqBytes := c.MustGet("RequestBytes").([]byte)
|
||||||
|
|
||||||
if cfg.VspClosed {
|
if cfg.VspClosed {
|
||||||
sendError(errVspClosed, c)
|
sendError(errVspClosed, c)
|
||||||
@ -37,7 +39,7 @@ func payFee(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var request payFeeRequest
|
var request payFeeRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
|
||||||
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
||||||
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
||||||
return
|
return
|
||||||
@ -257,8 +259,22 @@ findAddress:
|
|||||||
funcName, ticket.Hash, ticket.FeeTxHash)
|
funcName, ticket.Hash, ticket.FeeTxHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJSONResponse(payFeeResponse{
|
// Send success response to client.
|
||||||
|
resp, respSig := sendJSONResponse(payFeeResponse{
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
Request: request,
|
Request: request,
|
||||||
}, c)
|
}, c)
|
||||||
|
|
||||||
|
// Store a record of the vote choice change.
|
||||||
|
err = db.SaveVoteChange(
|
||||||
|
ticket.Hash,
|
||||||
|
database.VoteChangeRecord{
|
||||||
|
Request: string(reqBytes),
|
||||||
|
RequestSignature: c.GetHeader("VSP-Client-Signature"),
|
||||||
|
Response: resp,
|
||||||
|
ResponseSignature: respSig,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%s: Failed to store vote change record (ticketHash=%s): %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@ td.status-bad{
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.ticket-table td {
|
.ticket-table td {
|
||||||
|
font-size: 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,13 @@
|
|||||||
package webapi
|
package webapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/decred/vspd/database"
|
"github.com/decred/vspd/database"
|
||||||
"github.com/decred/vspd/rpc"
|
"github.com/decred/vspd/rpc"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setVoteChoices is the handler for "POST /api/v3/setvotechoices".
|
// setVoteChoices is the handler for "POST /api/v3/setvotechoices".
|
||||||
@ -20,6 +22,7 @@ func setVoteChoices(c *gin.Context) {
|
|||||||
ticket := c.MustGet("Ticket").(database.Ticket)
|
ticket := c.MustGet("Ticket").(database.Ticket)
|
||||||
knownTicket := c.MustGet("KnownTicket").(bool)
|
knownTicket := c.MustGet("KnownTicket").(bool)
|
||||||
walletClients := c.MustGet("WalletClients").([]*rpc.WalletRPC)
|
walletClients := c.MustGet("WalletClients").([]*rpc.WalletRPC)
|
||||||
|
reqBytes := c.MustGet("RequestBytes").([]byte)
|
||||||
|
|
||||||
// If we cannot set the vote choices on at least one voting wallet right
|
// If we cannot set the vote choices on at least one voting wallet right
|
||||||
// now, don't update the database, just return an error.
|
// now, don't update the database, just return an error.
|
||||||
@ -41,8 +44,17 @@ func setVoteChoices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only allow vote choices to be updated for live/immature tickets.
|
||||||
|
if ticket.Outcome != "" {
|
||||||
|
log.Warnf("%s: Ticket not eligible to vote (clientIP=%s, ticketHash=%s)",
|
||||||
|
funcName, c.ClientIP(), ticket.Hash)
|
||||||
|
sendErrorWithMsg(fmt.Sprintf("ticket not eligible to vote (status=%s)", ticket.Outcome),
|
||||||
|
errTicketCannotVote, c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var request setVoteChoicesRequest
|
var request setVoteChoicesRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
|
||||||
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
||||||
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
||||||
return
|
return
|
||||||
@ -88,10 +100,22 @@ func setVoteChoices(c *gin.Context) {
|
|||||||
|
|
||||||
// TODO: DB - error if given timestamp is older than any previous requests
|
// TODO: DB - error if given timestamp is older than any previous requests
|
||||||
|
|
||||||
// TODO: DB - store setvotechoices receipt in log
|
// Send success response to client.
|
||||||
|
resp, respSig := sendJSONResponse(setVoteChoicesResponse{
|
||||||
sendJSONResponse(setVoteChoicesResponse{
|
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
Request: request,
|
Request: request,
|
||||||
}, c)
|
}, c)
|
||||||
|
|
||||||
|
// Store a record of the vote choice change.
|
||||||
|
err = db.SaveVoteChange(
|
||||||
|
ticket.Hash,
|
||||||
|
database.VoteChangeRecord{
|
||||||
|
Request: string(reqBytes),
|
||||||
|
RequestSignature: c.GetHeader("VSP-Client-Signature"),
|
||||||
|
Response: resp,
|
||||||
|
ResponseSignature: respSig,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%s: Failed to store vote change record (ticketHash=%s): %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,73 +86,109 @@
|
|||||||
<div class="block__content">
|
<div class="block__content">
|
||||||
<h1>Ticket Search</h1>
|
<h1>Ticket Search</h1>
|
||||||
|
|
||||||
<form class="my-2" action="/admin/ticket" method="post">
|
<form class="mt-2 mb-4" action="/admin/ticket" method="post">
|
||||||
<input type="text" name="hash" size="64" minlength="64" maxlength="64" required placeholder="Ticket hash" autocomplete="off">
|
<input type="text" name="hash" size="64" minlength="64" maxlength="64" required placeholder="Ticket hash" autocomplete="off">
|
||||||
<button class="ml-3 btn btn-primary" type="submit">Search</button>
|
<button class="ml-3 btn btn-primary" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{ with .SearchResult }}
|
{{ with .SearchResult }}
|
||||||
{{ if .Found }}
|
{{ if .Found }}
|
||||||
{{ with .Ticket }}
|
<h1>Search Result</h1>
|
||||||
<table class="table ticket-table mt-4 mb-0">
|
<table class="table ticket-table mt-2 mb-4">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Hash</th>
|
<th>Hash</th>
|
||||||
<td>{{ .Hash }}</td>
|
<td>{{ .Ticket.Hash }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Commitment Address</th>
|
<th>Commitment Address</th>
|
||||||
<td>{{ .CommitmentAddress }}</td>
|
<td>{{ .Ticket.CommitmentAddress }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fee Address Index</th>
|
<th>Fee Address Index</th>
|
||||||
<td>{{ .FeeAddressIndex }}</td>
|
<td>{{ .Ticket.FeeAddressIndex }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fee Address</th>
|
<th>Fee Address</th>
|
||||||
<td>{{ .FeeAddress }}</td>
|
<td>{{ .Ticket.FeeAddress }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fee Amount</th>
|
<th>Fee Amount</th>
|
||||||
<td>{{ .FeeAmount }} atoms</td>
|
<td>{{ .Ticket.FeeAmount }} atoms</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Fee Expiration</th>
|
<th>Fee Expiration</th>
|
||||||
<td>{{ .FeeExpiration }}</td>
|
<td>{{ .Ticket.FeeExpiration }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Confirmed</th>
|
<th>Confirmed</th>
|
||||||
<td>{{ .Confirmed }}</td>
|
<td>{{ .Ticket.Confirmed }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Vote Choices</th>
|
<th>Current Vote Choices</th>
|
||||||
<td>
|
<td>
|
||||||
{{ range $key, $value := .VoteChoices }}
|
{{ range $key, $value := .Ticket.VoteChoices }}
|
||||||
{{ $key }}: {{ $value }} <br />
|
{{ $key }}: {{ $value }} <br />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Voting WIF</th>
|
<th>
|
||||||
<td>{{ .VotingWIF }}</td>
|
Vote Choice Changes<br />
|
||||||
</tr>
|
<em>({{ .MaxVoteChanges }} most recent)</em>
|
||||||
<tr>
|
</th>
|
||||||
<th>Fee Tx</th>
|
<td>
|
||||||
<td>{{ .FeeTxHex }}</td>
|
{{ range $key, $value := .VoteChanges }}
|
||||||
</tr>
|
<details>
|
||||||
<tr>
|
<summary>
|
||||||
<th>Fee Tx Hash</th>
|
{{ if eq $key 0}}
|
||||||
<td>{{ .FeeTxHash }}</td>
|
Initial choices
|
||||||
</tr>
|
{{ else }}
|
||||||
<tr>
|
Change {{ $key }}
|
||||||
<th>Fee Tx Status</th>
|
{{ end }}
|
||||||
<td>{{ .FeeTxStatus }}</td>
|
</summary>
|
||||||
</tr>
|
<table class="table ticket-table my-2">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ticket Outcome</th>
|
<th>Request</th>
|
||||||
<td>{{ .Outcome }}</td>
|
<td>{{ $value.Request }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
{{ end }}
|
<th>Request<br />Signature</th>
|
||||||
|
<td>{{ $value.RequestSignature }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Response</th>
|
||||||
|
<td>{{ $value.Response }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Response<br />Signature</th>
|
||||||
|
<td>{{ $value.ResponseSignature }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Voting WIF</th>
|
||||||
|
<td>{{ .Ticket.VotingWIF }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Fee Tx</th>
|
||||||
|
<td>{{ .Ticket.FeeTxHex }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Fee Tx Hash</th>
|
||||||
|
<td>{{ .Ticket.FeeTxHash }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Fee Tx Status</th>
|
||||||
|
<td>{{ .Ticket.FeeTxStatus }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket Outcome</th>
|
||||||
|
<td>{{ .Ticket.Outcome }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<p>No ticket found with hash <span class="code">{{ .Hash }}</span></p>
|
<p>No ticket found with hash <span class="code">{{ .Hash }}</span></p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/decred/vspd/database"
|
"github.com/decred/vspd/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ticketStatus is the handler for "POST /api/v3/ticketstatus".
|
// ticketStatus is the handler for "POST /api/v3/ticketstatus".
|
||||||
@ -18,6 +19,7 @@ func ticketStatus(c *gin.Context) {
|
|||||||
// Get values which have been added to context by middleware.
|
// Get values which have been added to context by middleware.
|
||||||
ticket := c.MustGet("Ticket").(database.Ticket)
|
ticket := c.MustGet("Ticket").(database.Ticket)
|
||||||
knownTicket := c.MustGet("KnownTicket").(bool)
|
knownTicket := c.MustGet("KnownTicket").(bool)
|
||||||
|
reqBytes := c.MustGet("RequestBytes").([]byte)
|
||||||
|
|
||||||
if !knownTicket {
|
if !knownTicket {
|
||||||
log.Warnf("%s: Unknown ticket (clientIP=%s)", funcName, c.ClientIP())
|
log.Warnf("%s: Unknown ticket (clientIP=%s)", funcName, c.ClientIP())
|
||||||
@ -26,7 +28,7 @@ func ticketStatus(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var request ticketStatusRequest
|
var request ticketStatusRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := binding.JSON.BindBody(reqBytes, &request); err != nil {
|
||||||
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err)
|
||||||
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
sendErrorWithMsg(err.Error(), errBadRequest, c)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -23,14 +23,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
VSPFee float64
|
VSPFee float64
|
||||||
NetParams *chaincfg.Params
|
NetParams *chaincfg.Params
|
||||||
FeeAccountName string
|
FeeAccountName string
|
||||||
SupportEmail string
|
SupportEmail string
|
||||||
VspClosed bool
|
VspClosed bool
|
||||||
AdminPass string
|
AdminPass string
|
||||||
Debug bool
|
Debug bool
|
||||||
Designation string
|
Designation string
|
||||||
|
MaxVoteChangeRecords int
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -227,28 +228,34 @@ func router(debugMode bool, cookieSecret []byte, dcrd rpc.DcrdConnect, wallets r
|
|||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendJSONResponse(resp interface{}, c *gin.Context) {
|
// sendJSONResponse serializes the provided response, signs it, and sends the
|
||||||
|
// response to the client with a 200 OK status. Returns the seralized response
|
||||||
|
// and the signature.
|
||||||
|
func sendJSONResponse(resp interface{}, c *gin.Context) (string, string) {
|
||||||
dec, err := json.Marshal(resp)
|
dec, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("JSON marshal error: %v", err)
|
log.Errorf("JSON marshal error: %v", err)
|
||||||
sendError(errInternalError, c)
|
sendError(errInternalError, c)
|
||||||
return
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
sig := ed25519.Sign(signPrivKey, dec)
|
sig := ed25519.Sign(signPrivKey, dec)
|
||||||
c.Writer.Header().Set("VSP-Server-Signature", base64.StdEncoding.EncodeToString(sig))
|
sigStr := base64.StdEncoding.EncodeToString(sig)
|
||||||
|
c.Writer.Header().Set("VSP-Server-Signature", sigStr)
|
||||||
|
|
||||||
c.AbortWithStatusJSON(http.StatusOK, resp)
|
c.AbortWithStatusJSON(http.StatusOK, resp)
|
||||||
|
|
||||||
|
return string(dec), sigStr
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendError returns an error response to the client using the default error
|
// sendError sends an error response to the client using the default error
|
||||||
// message.
|
// message.
|
||||||
func sendError(e apiError, c *gin.Context) {
|
func sendError(e apiError, c *gin.Context) {
|
||||||
msg := e.defaultMessage()
|
msg := e.defaultMessage()
|
||||||
sendErrorWithMsg(msg, e, c)
|
sendErrorWithMsg(msg, e, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendErrorWithMsg returns an error response to the client using the provided
|
// sendErrorWithMsg sends an error response to the client using the provided
|
||||||
// error message.
|
// error message.
|
||||||
func sendErrorWithMsg(msg string, e apiError, c *gin.Context) {
|
func sendErrorWithMsg(msg string, e apiError, c *gin.Context) {
|
||||||
status := e.httpStatus()
|
status := e.httpStatus()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user