github.com/decred/politeia@v1.4.0/politeiawww/legacy/proposals.go (about)

     1  // Copyright (c) 2017-2021 The Decred developers
     2  // Use of this source code is governed by an ISC
     3  // license that can be found in the LICENSE file.
     4  
     5  package legacy
     6  
     7  import (
     8  	"context"
     9  	"encoding/base64"
    10  	"encoding/hex"
    11  	"encoding/json"
    12  	"errors"
    13  	"io"
    14  	"strconv"
    15  	"strings"
    16  
    17  	pdv2 "github.com/decred/politeia/politeiad/api/v2"
    18  	"github.com/decred/politeia/politeiad/backend/gitbe/decredplugin"
    19  	piplugin "github.com/decred/politeia/politeiad/plugins/pi"
    20  	"github.com/decred/politeia/politeiad/plugins/ticketvote"
    21  	tkplugin "github.com/decred/politeia/politeiad/plugins/ticketvote"
    22  	"github.com/decred/politeia/politeiad/plugins/usermd"
    23  	umplugin "github.com/decred/politeia/politeiad/plugins/usermd"
    24  	rcv1 "github.com/decred/politeia/politeiawww/api/records/v1"
    25  	www "github.com/decred/politeia/politeiawww/api/www/v1"
    26  	"github.com/decred/politeia/politeiawww/legacy/user"
    27  	"github.com/decred/politeia/util"
    28  	"github.com/google/uuid"
    29  )
    30  
    31  func (p *Politeiawww) proposals(ctx context.Context, reqs []pdv2.RecordRequest) (map[string]www.ProposalRecord, error) {
    32  	// Break the requests up so that they do not exceed the politeiad
    33  	// records page size.
    34  	var startIdx int
    35  	proposals := make(map[string]www.ProposalRecord, len(reqs))
    36  	for startIdx < len(reqs) {
    37  		// Setup a page of requests
    38  		endIdx := startIdx + int(pdv2.RecordsPageSize)
    39  		if endIdx > len(reqs) {
    40  			endIdx = len(reqs)
    41  		}
    42  
    43  		page := reqs[startIdx:endIdx]
    44  		records, err := p.politeiad.Records(ctx, page)
    45  		if err != nil {
    46  			return nil, err
    47  		}
    48  
    49  		// Get records' comment counts
    50  		tokens := make([]string, 0, len(page))
    51  		for _, r := range page {
    52  			tokens = append(tokens, r.Token)
    53  		}
    54  		counts, err := p.politeiad.CommentCount(ctx, tokens)
    55  		if err != nil {
    56  			return nil, err
    57  		}
    58  
    59  		for k, v := range records {
    60  			// Legacy www routes are only for vetted records
    61  			if v.State == pdv2.RecordStateUnvetted {
    62  				continue
    63  			}
    64  
    65  			// Convert to a proposal
    66  			pr, err := convertRecordToProposal(v)
    67  			if err != nil {
    68  				return nil, err
    69  			}
    70  
    71  			count := counts[k]
    72  			pr.NumComments = uint(count)
    73  
    74  			// Get submissions list if this is an RFP
    75  			if pr.LinkBy != 0 {
    76  				subs, err := p.politeiad.TicketVoteSubmissions(ctx,
    77  					pr.CensorshipRecord.Token)
    78  				if err != nil {
    79  					return nil, err
    80  				}
    81  				pr.LinkedFrom = subs
    82  			}
    83  
    84  			// Fill in user data
    85  			userID := userIDFromMetadataStreams(v.Metadata)
    86  			uid, err := uuid.Parse(userID)
    87  			if err != nil {
    88  				return nil, err
    89  			}
    90  			u, err := p.db.UserGetById(uid)
    91  			if err != nil {
    92  				return nil, err
    93  			}
    94  			pr.Username = u.Username
    95  
    96  			proposals[k] = *pr
    97  		}
    98  
    99  		// Update the index
   100  		startIdx = endIdx
   101  	}
   102  
   103  	return proposals, nil
   104  }
   105  
   106  func (p *Politeiawww) processTokenInventory(ctx context.Context, isAdmin bool) (*www.TokenInventoryReply, error) {
   107  	log.Tracef("processTokenInventory")
   108  
   109  	// Get record inventory
   110  	ir, err := p.politeiad.Inventory(ctx, pdv2.RecordStateInvalid,
   111  		pdv2.RecordStatusInvalid, 0)
   112  	if err != nil {
   113  		return nil, err
   114  	}
   115  
   116  	// Get vote inventory
   117  	ti := ticketvote.Inventory{}
   118  	vir, err := p.politeiad.TicketVoteInventory(ctx, ti)
   119  	if err != nil {
   120  		return nil, err
   121  	}
   122  
   123  	var (
   124  		// Unvetted
   125  		statusUnreviewed = pdv2.RecordStatuses[pdv2.RecordStatusUnreviewed]
   126  		statusCensored   = pdv2.RecordStatuses[pdv2.RecordStatusCensored]
   127  		statusArchived   = pdv2.RecordStatuses[pdv2.RecordStatusArchived]
   128  
   129  		unreviewed = ir.Unvetted[statusUnreviewed]
   130  		censored   = ir.Unvetted[statusCensored]
   131  
   132  		// Human readable vote statuses
   133  		statusUnauth   = tkplugin.VoteStatuses[tkplugin.VoteStatusUnauthorized]
   134  		statusAuth     = tkplugin.VoteStatuses[tkplugin.VoteStatusAuthorized]
   135  		statusStarted  = tkplugin.VoteStatuses[tkplugin.VoteStatusStarted]
   136  		statusApproved = tkplugin.VoteStatuses[tkplugin.VoteStatusApproved]
   137  		statusRejected = tkplugin.VoteStatuses[tkplugin.VoteStatusRejected]
   138  
   139  		// Vetted
   140  		unauth    = vir.Tokens[statusUnauth]
   141  		auth      = vir.Tokens[statusAuth]
   142  		pre       = append(unauth, auth...)
   143  		active    = vir.Tokens[statusStarted]
   144  		approved  = vir.Tokens[statusApproved]
   145  		rejected  = vir.Tokens[statusRejected]
   146  		abandoned = ir.Vetted[statusArchived]
   147  	)
   148  
   149  	// Only return unvetted tokens to admins
   150  	if isAdmin {
   151  		unreviewed = []string{}
   152  		censored = []string{}
   153  	}
   154  
   155  	// Return empty arrays and not nils
   156  	if unreviewed == nil {
   157  		unreviewed = []string{}
   158  	}
   159  	if censored == nil {
   160  		censored = []string{}
   161  	}
   162  	if pre == nil {
   163  		pre = []string{}
   164  	}
   165  	if active == nil {
   166  		active = []string{}
   167  	}
   168  	if approved == nil {
   169  		approved = []string{}
   170  	}
   171  	if rejected == nil {
   172  		rejected = []string{}
   173  	}
   174  	if abandoned == nil {
   175  		abandoned = []string{}
   176  	}
   177  
   178  	return &www.TokenInventoryReply{
   179  		Unreviewed: unreviewed,
   180  		Censored:   censored,
   181  		Pre:        pre,
   182  		Active:     active,
   183  		Approved:   approved,
   184  		Rejected:   rejected,
   185  		Abandoned:  abandoned,
   186  	}, nil
   187  }
   188  
   189  func (p *Politeiawww) processAllVetted(ctx context.Context, gav www.GetAllVetted) (*www.GetAllVettedReply, error) {
   190  	log.Tracef("processAllVetted: %v %v", gav.Before, gav.After)
   191  
   192  	// NOTE: this route is not scalable and needs to be removed ASAP.
   193  	// It only needs to be supported to give dcrdata a change to switch
   194  	// to the records API.
   195  
   196  	// The Before and After arguments are NO LONGER SUPPORTED. This
   197  	// route will only return a single page of vetted tokens. The
   198  	// records API InventoryOrdered command should be used instead.
   199  	tokens, err := p.politeiad.InventoryOrdered(ctx, pdv2.RecordStateVetted, 1)
   200  	if err != nil {
   201  		return nil, err
   202  	}
   203  
   204  	// Get the proposals without any files
   205  	reqs := make([]pdv2.RecordRequest, 0, pdv2.RecordsPageSize)
   206  	for _, v := range tokens {
   207  		reqs = append(reqs, pdv2.RecordRequest{
   208  			Token: v,
   209  			Filenames: []string{
   210  				piplugin.FileNameProposalMetadata,
   211  				tkplugin.FileNameVoteMetadata,
   212  			},
   213  		})
   214  	}
   215  	props, err := p.proposals(ctx, reqs)
   216  	if err != nil {
   217  		return nil, err
   218  	}
   219  
   220  	// Covert proposal map to an slice
   221  	proposals := make([]www.ProposalRecord, 0, len(props))
   222  	for _, v := range tokens {
   223  		pr, ok := props[v]
   224  		if !ok {
   225  			continue
   226  		}
   227  		proposals = append(proposals, pr)
   228  	}
   229  
   230  	return &www.GetAllVettedReply{
   231  		Proposals: proposals,
   232  	}, nil
   233  }
   234  
   235  func (p *Politeiawww) processProposalDetails(ctx context.Context, pd www.ProposalsDetails, u *user.User) (*www.ProposalDetailsReply, error) {
   236  	log.Tracef("processProposalDetails: %v", pd.Token)
   237  
   238  	// Parse version
   239  	var version uint64
   240  	var err error
   241  	if pd.Version != "" {
   242  		version, err = strconv.ParseUint(pd.Version, 10, 64)
   243  		if err != nil {
   244  			return nil, www.UserError{
   245  				ErrorCode: www.ErrorStatusProposalNotFound,
   246  			}
   247  		}
   248  	}
   249  
   250  	// Get proposal
   251  	reqs := []pdv2.RecordRequest{
   252  		{
   253  			Token:   pd.Token,
   254  			Version: uint32(version),
   255  		},
   256  	}
   257  	prs, err := p.proposals(ctx, reqs)
   258  	if err != nil {
   259  		return nil, err
   260  	}
   261  	pr, ok := prs[pd.Token]
   262  	if !ok {
   263  		return nil, www.UserError{
   264  			ErrorCode: www.ErrorStatusProposalNotFound,
   265  		}
   266  	}
   267  
   268  	return &www.ProposalDetailsReply{
   269  		Proposal: pr,
   270  	}, nil
   271  }
   272  
   273  func (p *Politeiawww) processBatchProposals(ctx context.Context, bp www.BatchProposals, u *user.User) (*www.BatchProposalsReply, error) {
   274  	log.Tracef("processBatchProposals: %v", bp.Tokens)
   275  
   276  	if len(bp.Tokens) > www.ProposalListPageSize {
   277  		return nil, www.UserError{
   278  			ErrorCode: www.ErrorStatusMaxProposalsExceededPolicy,
   279  		}
   280  	}
   281  
   282  	// Get the proposals batch
   283  	reqs := make([]pdv2.RecordRequest, 0, len(bp.Tokens))
   284  	for _, v := range bp.Tokens {
   285  		reqs = append(reqs, pdv2.RecordRequest{
   286  			Token: v,
   287  			Filenames: []string{
   288  				piplugin.FileNameProposalMetadata,
   289  				tkplugin.FileNameVoteMetadata,
   290  			},
   291  		})
   292  	}
   293  	props, err := p.proposals(ctx, reqs)
   294  	if err != nil {
   295  		return nil, err
   296  	}
   297  
   298  	// Return the proposals in the same order they were requests in.
   299  	proposals := make([]www.ProposalRecord, 0, len(props))
   300  	for _, v := range bp.Tokens {
   301  		pr, ok := props[v]
   302  		if !ok {
   303  			continue
   304  		}
   305  		proposals = append(proposals, pr)
   306  	}
   307  
   308  	return &www.BatchProposalsReply{
   309  		Proposals: proposals,
   310  	}, nil
   311  }
   312  
   313  func (p *Politeiawww) processBatchVoteSummary(ctx context.Context, bvs www.BatchVoteSummary) (*www.BatchVoteSummaryReply, error) {
   314  	log.Tracef("processBatchVoteSummary: %v", bvs.Tokens)
   315  
   316  	if len(bvs.Tokens) > www.ProposalListPageSize {
   317  		return nil, www.UserError{
   318  			ErrorCode: www.ErrorStatusMaxProposalsExceededPolicy,
   319  		}
   320  	}
   321  
   322  	// Get vote summaries
   323  	vs, err := p.politeiad.TicketVoteSummaries(ctx, bvs.Tokens)
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	// Prepare reply
   329  	var bestBlock uint32
   330  	summaries := make(map[string]www.VoteSummary, len(vs))
   331  	for token, v := range vs {
   332  		bestBlock = v.BestBlock
   333  		results := make([]www.VoteOptionResult, len(v.Results))
   334  		for k, r := range v.Results {
   335  			results[k] = www.VoteOptionResult{
   336  				VotesReceived: r.Votes,
   337  				Option: www.VoteOption{
   338  					Id:          r.ID,
   339  					Description: r.Description,
   340  					Bits:        r.VoteBit,
   341  				},
   342  			}
   343  		}
   344  		summaries[token] = www.VoteSummary{
   345  			Status:           convertVoteStatusToWWW(v.Status),
   346  			Type:             convertVoteTypeToWWW(v.Type),
   347  			Approved:         v.Status == tkplugin.VoteStatusApproved,
   348  			EligibleTickets:  v.EligibleTickets,
   349  			Duration:         v.Duration,
   350  			EndHeight:        uint64(v.EndBlockHeight),
   351  			QuorumPercentage: v.QuorumPercentage,
   352  			PassPercentage:   v.PassPercentage,
   353  			Results:          results,
   354  		}
   355  	}
   356  
   357  	return &www.BatchVoteSummaryReply{
   358  		Summaries: summaries,
   359  		BestBlock: uint64(bestBlock),
   360  	}, nil
   361  }
   362  
   363  func (p *Politeiawww) processVoteStatus(ctx context.Context, token string) (*www.VoteStatusReply, error) {
   364  	log.Tracef("processVoteStatus")
   365  
   366  	// Get vote summaries
   367  	summaries, err := p.politeiad.TicketVoteSummaries(ctx, []string{token})
   368  	if err != nil {
   369  		return nil, err
   370  	}
   371  	s, ok := summaries[token]
   372  	if !ok {
   373  		return nil, www.UserError{
   374  			ErrorCode: www.ErrorStatusProposalNotFound,
   375  		}
   376  	}
   377  	vsr := convertVoteStatusReply(token, s)
   378  
   379  	return &vsr, nil
   380  }
   381  
   382  func (p *Politeiawww) processAllVoteStatus(ctx context.Context) (*www.GetAllVoteStatusReply, error) {
   383  	log.Tracef("processAllVoteStatus")
   384  
   385  	// NOTE: This route is suppose to return the vote status of all
   386  	// public proposals. This is horrendously unscalable. We are only
   387  	// supporting this route until dcrdata has a chance to update and
   388  	// use the ticketvote API. Until then, we only return a single page
   389  	// of vote statuses.
   390  
   391  	// Get a page of vetted records
   392  	tokens, err := p.politeiad.InventoryOrdered(ctx, pdv2.RecordStateVetted, 1)
   393  	if err != nil {
   394  		return nil, err
   395  	}
   396  
   397  	// Get vote summaries
   398  	vs, err := p.politeiad.TicketVoteSummaries(ctx, tokens)
   399  	if err != nil {
   400  		return nil, err
   401  	}
   402  
   403  	// Prepare reply
   404  	statuses := make([]www.VoteStatusReply, 0, len(vs))
   405  	for token, v := range vs {
   406  		statuses = append(statuses, convertVoteStatusReply(token, v))
   407  	}
   408  
   409  	return &www.GetAllVoteStatusReply{
   410  		VotesStatus: statuses,
   411  	}, nil
   412  }
   413  
   414  func convertVoteDetails(vd tkplugin.VoteDetails) (www.StartVote, www.StartVoteReply) {
   415  	options := make([]www.VoteOption, 0, len(vd.Params.Options))
   416  	for _, v := range vd.Params.Options {
   417  		options = append(options, www.VoteOption{
   418  			Id:          v.ID,
   419  			Description: v.Description,
   420  			Bits:        v.Bit,
   421  		})
   422  	}
   423  	sv := www.StartVote{
   424  		Vote: www.Vote{
   425  			Token:            vd.Params.Token,
   426  			Mask:             vd.Params.Mask,
   427  			Duration:         vd.Params.Duration,
   428  			QuorumPercentage: vd.Params.QuorumPercentage,
   429  			PassPercentage:   vd.Params.PassPercentage,
   430  			Options:          options,
   431  		},
   432  		PublicKey: vd.PublicKey,
   433  		Signature: vd.Signature,
   434  	}
   435  	svr := www.StartVoteReply{
   436  		StartBlockHeight: strconv.FormatUint(uint64(vd.StartBlockHeight), 10),
   437  		StartBlockHash:   vd.StartBlockHash,
   438  		EndHeight:        strconv.FormatUint(uint64(vd.EndBlockHeight), 10),
   439  		EligibleTickets:  vd.EligibleTickets,
   440  	}
   441  
   442  	return sv, svr
   443  }
   444  
   445  func (p *Politeiawww) processActiveVote(ctx context.Context) (*www.ActiveVoteReply, error) {
   446  	log.Tracef("processActiveVotes")
   447  
   448  	// Get a page of ongoing votes. This route is deprecated and should
   449  	// be deleted before the time comes when more than a page of ongoing
   450  	// votes is required.
   451  	i := ticketvote.Inventory{}
   452  	ir, err := p.politeiad.TicketVoteInventory(ctx, i)
   453  	if err != nil {
   454  		return nil, err
   455  	}
   456  	s := ticketvote.VoteStatuses[ticketvote.VoteStatusStarted]
   457  	started := ir.Tokens[s]
   458  
   459  	if len(started) == 0 {
   460  		// No active votes
   461  		return &www.ActiveVoteReply{
   462  			Votes: []www.ProposalVoteTuple{},
   463  		}, nil
   464  	}
   465  
   466  	// Get proposals
   467  	reqs := make([]pdv2.RecordRequest, 0, pdv2.RecordsPageSize)
   468  	for _, v := range started {
   469  		reqs = append(reqs, pdv2.RecordRequest{
   470  			Token: v,
   471  			Filenames: []string{
   472  				piplugin.FileNameProposalMetadata,
   473  				tkplugin.FileNameVoteMetadata,
   474  			},
   475  		})
   476  	}
   477  	props, err := p.proposals(ctx, reqs)
   478  	if err != nil {
   479  		return nil, err
   480  	}
   481  
   482  	// Get vote details
   483  	voteDetails := make(map[string]tkplugin.VoteDetails, len(started))
   484  	for _, v := range started {
   485  		dr, err := p.politeiad.TicketVoteDetails(ctx, v)
   486  		if err != nil {
   487  			return nil, err
   488  		}
   489  		if dr.Vote == nil {
   490  			continue
   491  		}
   492  		voteDetails[v] = *dr.Vote
   493  	}
   494  
   495  	// Prepare reply
   496  	votes := make([]www.ProposalVoteTuple, 0, len(started))
   497  	for _, v := range started {
   498  		var (
   499  			proposal www.ProposalRecord
   500  			sv       www.StartVote
   501  			svr      www.StartVoteReply
   502  			ok       bool
   503  		)
   504  		proposal, ok = props[v]
   505  		if !ok {
   506  			continue
   507  		}
   508  		vd, ok := voteDetails[v]
   509  		if ok {
   510  			sv, svr = convertVoteDetails(vd)
   511  			votes = append(votes, www.ProposalVoteTuple{
   512  				Proposal:       proposal,
   513  				StartVote:      sv,
   514  				StartVoteReply: svr,
   515  			})
   516  		}
   517  	}
   518  
   519  	return &www.ActiveVoteReply{
   520  		Votes: votes,
   521  	}, nil
   522  }
   523  
   524  func (p *Politeiawww) processCastVotes(ctx context.Context, ballot *www.Ballot) (*www.BallotReply, error) {
   525  	log.Tracef("processCastVotes")
   526  
   527  	// Verify there is work to do
   528  	if len(ballot.Votes) == 0 {
   529  		return &www.BallotReply{
   530  			Receipts: []www.CastVoteReply{},
   531  		}, nil
   532  	}
   533  
   534  	// Prepare plugin command
   535  	votes := make([]tkplugin.CastVote, 0, len(ballot.Votes))
   536  	var token string
   537  	for _, v := range ballot.Votes {
   538  		token = v.Token
   539  		votes = append(votes, tkplugin.CastVote{
   540  			Token:     v.Token,
   541  			Ticket:    v.Ticket,
   542  			VoteBit:   v.VoteBit,
   543  			Signature: v.Signature,
   544  		})
   545  	}
   546  	cb := tkplugin.CastBallot{
   547  		Ballot: votes,
   548  	}
   549  
   550  	// Send plugin command
   551  	cbr, err := p.politeiad.TicketVoteCastBallot(ctx, token, cb)
   552  	if err != nil {
   553  		return nil, err
   554  	}
   555  
   556  	// Prepare reply
   557  	receipts := make([]www.CastVoteReply, 0, len(cbr.Receipts))
   558  	for k, v := range cbr.Receipts {
   559  		receipts = append(receipts, www.CastVoteReply{
   560  			ClientSignature: ballot.Votes[k].Signature,
   561  			Signature:       v.Receipt,
   562  			Error:           v.ErrorContext,
   563  			ErrorStatus:     convertVoteErrorCodeToWWW(v.ErrorCode),
   564  		})
   565  	}
   566  
   567  	return &www.BallotReply{
   568  		Receipts: receipts,
   569  	}, nil
   570  }
   571  
   572  func (p *Politeiawww) processVoteResults(ctx context.Context, token string) (*www.VoteResultsReply, error) {
   573  	log.Tracef("processVoteResults: %v", token)
   574  
   575  	// Get vote details
   576  	dr, err := p.politeiad.TicketVoteDetails(ctx, token)
   577  	if err != nil {
   578  		return nil, err
   579  	}
   580  	if dr.Vote == nil {
   581  		return &www.VoteResultsReply{}, nil
   582  	}
   583  	sv, svr := convertVoteDetails(*dr.Vote)
   584  
   585  	// Get cast votes
   586  	rr, err := p.politeiad.TicketVoteResults(ctx, token)
   587  	if err != nil {
   588  		return nil, err
   589  	}
   590  
   591  	// Convert to www
   592  	votes := make([]www.CastVote, 0, len(rr.Votes))
   593  	for _, v := range rr.Votes {
   594  		votes = append(votes, www.CastVote{
   595  			Token:     v.Token,
   596  			Ticket:    v.Ticket,
   597  			VoteBit:   v.VoteBit,
   598  			Signature: v.Signature,
   599  		})
   600  	}
   601  
   602  	return &www.VoteResultsReply{
   603  		StartVote:      sv,
   604  		StartVoteReply: svr,
   605  		CastVotes:      votes,
   606  	}, nil
   607  }
   608  
   609  // userMetadataDecode decodes and returns the UserMetadata from the provided
   610  // metadata streams. If a UserMetadata is not found, nil is returned.
   611  func userMetadataDecode(ms []pdv2.MetadataStream) (*umplugin.UserMetadata, error) {
   612  	var userMD *umplugin.UserMetadata
   613  	for _, v := range ms {
   614  		if v.PluginID != usermd.PluginID ||
   615  			v.StreamID != umplugin.StreamIDUserMetadata {
   616  			// This is not user metadata
   617  			continue
   618  		}
   619  		var um umplugin.UserMetadata
   620  		err := json.Unmarshal([]byte(v.Payload), &um)
   621  		if err != nil {
   622  			return nil, err
   623  		}
   624  		userMD = &um
   625  		break
   626  	}
   627  	return userMD, nil
   628  }
   629  
   630  // userIDFromMetadataStreams searches for a UserMetadata and parses the user ID
   631  // from it if found. An empty string is returned if no UserMetadata is found.
   632  func userIDFromMetadataStreams(ms []pdv2.MetadataStream) string {
   633  	um, err := userMetadataDecode(ms)
   634  	if err != nil {
   635  		return ""
   636  	}
   637  	if um == nil {
   638  		return ""
   639  	}
   640  	return um.UserID
   641  }
   642  
   643  func convertStatusToWWW(status pdv2.RecordStatusT) www.PropStatusT {
   644  	switch status {
   645  	case pdv2.RecordStatusInvalid:
   646  		return www.PropStatusInvalid
   647  	case pdv2.RecordStatusPublic:
   648  		return www.PropStatusPublic
   649  	case pdv2.RecordStatusCensored:
   650  		return www.PropStatusCensored
   651  	case pdv2.RecordStatusArchived:
   652  		return www.PropStatusAbandoned
   653  	default:
   654  		return www.PropStatusInvalid
   655  	}
   656  }
   657  
   658  func convertRecordToProposal(r pdv2.Record) (*www.ProposalRecord, error) {
   659  	// Decode metadata
   660  	var (
   661  		um       *umplugin.UserMetadata
   662  		statuses = make([]umplugin.StatusChangeMetadata, 0, 16)
   663  	)
   664  	for _, v := range r.Metadata {
   665  		if v.PluginID != umplugin.PluginID {
   666  			continue
   667  		}
   668  
   669  		// This is a usermd plugin metadata stream
   670  		switch v.StreamID {
   671  		case umplugin.StreamIDUserMetadata:
   672  			var m umplugin.UserMetadata
   673  			err := json.Unmarshal([]byte(v.Payload), &m)
   674  			if err != nil {
   675  				return nil, err
   676  			}
   677  			um = &m
   678  		case umplugin.StreamIDStatusChanges:
   679  			d := json.NewDecoder(strings.NewReader(v.Payload))
   680  			for {
   681  				var sc umplugin.StatusChangeMetadata
   682  				err := d.Decode(&sc)
   683  				if errors.Is(err, io.EOF) {
   684  					break
   685  				} else if err != nil {
   686  					return nil, err
   687  				}
   688  				statuses = append(statuses, sc)
   689  			}
   690  		}
   691  	}
   692  
   693  	// Convert files
   694  	var (
   695  		name, linkTo string
   696  		linkBy       int64
   697  		files        = make([]www.File, 0, len(r.Files))
   698  	)
   699  	for _, v := range r.Files {
   700  		switch v.Name {
   701  		case piplugin.FileNameProposalMetadata:
   702  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   703  			if err != nil {
   704  				return nil, err
   705  			}
   706  			var pm piplugin.ProposalMetadata
   707  			err = json.Unmarshal(b, &pm)
   708  			if err != nil {
   709  				return nil, err
   710  			}
   711  			name = pm.Name
   712  
   713  		case tkplugin.FileNameVoteMetadata:
   714  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   715  			if err != nil {
   716  				return nil, err
   717  			}
   718  			var vm tkplugin.VoteMetadata
   719  			err = json.Unmarshal(b, &vm)
   720  			if err != nil {
   721  				return nil, err
   722  			}
   723  			linkTo = vm.LinkTo
   724  			linkBy = vm.LinkBy
   725  
   726  		default:
   727  			files = append(files, www.File{
   728  				Name:    v.Name,
   729  				MIME:    v.MIME,
   730  				Digest:  v.Digest,
   731  				Payload: v.Payload,
   732  			})
   733  		}
   734  	}
   735  
   736  	// Setup user defined metadata
   737  	pm := www.ProposalMetadata{
   738  		Name:   name,
   739  		LinkTo: linkTo,
   740  		LinkBy: linkBy,
   741  	}
   742  	b, err := json.Marshal(pm)
   743  	if err != nil {
   744  		return nil, err
   745  	}
   746  	metadata := []www.Metadata{
   747  		{
   748  			Digest:  hex.EncodeToString(util.Digest(b)),
   749  			Hint:    www.HintProposalMetadata,
   750  			Payload: base64.StdEncoding.EncodeToString(b),
   751  		},
   752  	}
   753  
   754  	var (
   755  		publishedAt, censoredAt, abandonedAt int64
   756  		changeMsg                            string
   757  		changeMsgTimestamp                   int64
   758  	)
   759  	for _, v := range statuses {
   760  		if v.Timestamp > changeMsgTimestamp {
   761  			changeMsg = v.Reason
   762  			changeMsgTimestamp = v.Timestamp
   763  		}
   764  		switch rcv1.RecordStatusT(v.Status) {
   765  		case rcv1.RecordStatusPublic:
   766  			publishedAt = v.Timestamp
   767  		case rcv1.RecordStatusCensored:
   768  			censoredAt = v.Timestamp
   769  		case rcv1.RecordStatusArchived:
   770  			abandonedAt = v.Timestamp
   771  		}
   772  	}
   773  
   774  	return &www.ProposalRecord{
   775  		Name:                pm.Name,
   776  		State:               www.PropStateVetted,
   777  		Status:              convertStatusToWWW(r.Status),
   778  		Timestamp:           r.Timestamp,
   779  		UserId:              um.UserID,
   780  		Username:            "", // Intentionally omitted
   781  		PublicKey:           um.PublicKey,
   782  		Signature:           um.Signature,
   783  		Version:             strconv.FormatUint(uint64(r.Version), 10),
   784  		StatusChangeMessage: changeMsg,
   785  		PublishedAt:         publishedAt,
   786  		CensoredAt:          censoredAt,
   787  		AbandonedAt:         abandonedAt,
   788  		LinkTo:              pm.LinkTo,
   789  		LinkBy:              pm.LinkBy,
   790  		LinkedFrom:          []string{},
   791  		Files:               files,
   792  		Metadata:            metadata,
   793  		CensorshipRecord: www.CensorshipRecord{
   794  			Token:     r.CensorshipRecord.Token,
   795  			Merkle:    r.CensorshipRecord.Merkle,
   796  			Signature: r.CensorshipRecord.Signature,
   797  		},
   798  	}, nil
   799  }
   800  
   801  func convertVoteStatusToWWW(status tkplugin.VoteStatusT) www.PropVoteStatusT {
   802  	switch status {
   803  	case tkplugin.VoteStatusInvalid:
   804  		return www.PropVoteStatusInvalid
   805  	case tkplugin.VoteStatusUnauthorized:
   806  		return www.PropVoteStatusNotAuthorized
   807  	case tkplugin.VoteStatusAuthorized:
   808  		return www.PropVoteStatusAuthorized
   809  	case tkplugin.VoteStatusStarted:
   810  		return www.PropVoteStatusStarted
   811  	case tkplugin.VoteStatusFinished:
   812  		return www.PropVoteStatusFinished
   813  	case tkplugin.VoteStatusApproved:
   814  		return www.PropVoteStatusFinished
   815  	case tkplugin.VoteStatusRejected:
   816  		return www.PropVoteStatusFinished
   817  	default:
   818  		return www.PropVoteStatusInvalid
   819  	}
   820  }
   821  
   822  func convertVoteTypeToWWW(t tkplugin.VoteT) www.VoteT {
   823  	switch t {
   824  	case tkplugin.VoteTypeInvalid:
   825  		return www.VoteTypeInvalid
   826  	case tkplugin.VoteTypeStandard:
   827  		return www.VoteTypeStandard
   828  	case tkplugin.VoteTypeRunoff:
   829  		return www.VoteTypeRunoff
   830  	default:
   831  		return www.VoteTypeInvalid
   832  	}
   833  }
   834  
   835  func convertVoteErrorCodeToWWW(e *tkplugin.VoteErrorT) decredplugin.ErrorStatusT {
   836  	if e == nil {
   837  		return decredplugin.ErrorStatusInvalid
   838  	}
   839  	switch *e {
   840  	case tkplugin.VoteErrorInvalid:
   841  		return decredplugin.ErrorStatusInvalid
   842  	case tkplugin.VoteErrorInternalError:
   843  		return decredplugin.ErrorStatusInternalError
   844  	case tkplugin.VoteErrorRecordNotFound:
   845  		return decredplugin.ErrorStatusProposalNotFound
   846  	case tkplugin.VoteErrorMultipleRecordVotes:
   847  		// There is not decredplugin error code for this
   848  	case tkplugin.VoteErrorVoteStatusInvalid:
   849  		return decredplugin.ErrorStatusVoteHasEnded
   850  	case tkplugin.VoteErrorVoteBitInvalid:
   851  		return decredplugin.ErrorStatusInvalidVoteBit
   852  	case tkplugin.VoteErrorSignatureInvalid:
   853  		// There is not decredplugin error code for this
   854  	case tkplugin.VoteErrorTicketNotEligible:
   855  		return decredplugin.ErrorStatusIneligibleTicket
   856  	case tkplugin.VoteErrorTicketAlreadyVoted:
   857  		return decredplugin.ErrorStatusDuplicateVote
   858  	default:
   859  	}
   860  	return decredplugin.ErrorStatusInternalError
   861  }
   862  
   863  func convertVoteStatusReply(token string, s tkplugin.SummaryReply) www.VoteStatusReply {
   864  	results := make([]www.VoteOptionResult, 0, len(s.Results))
   865  	var totalVotes uint64
   866  	for _, v := range s.Results {
   867  		totalVotes += v.Votes
   868  		results = append(results, www.VoteOptionResult{
   869  			VotesReceived: v.Votes,
   870  			Option: www.VoteOption{
   871  				Id:          v.ID,
   872  				Description: v.Description,
   873  				Bits:        v.VoteBit,
   874  			},
   875  		})
   876  	}
   877  	return www.VoteStatusReply{
   878  		Token:              token,
   879  		Status:             convertVoteStatusToWWW(s.Status),
   880  		TotalVotes:         totalVotes,
   881  		OptionsResult:      results,
   882  		EndHeight:          strconv.FormatUint(uint64(s.EndBlockHeight), 10),
   883  		BestBlock:          strconv.FormatUint(uint64(s.BestBlock), 10),
   884  		NumOfEligibleVotes: int(s.EligibleTickets),
   885  		QuorumPercentage:   s.QuorumPercentage,
   886  		PassPercentage:     s.PassPercentage,
   887  	}
   888  }