github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/pi/hooks.go (about)

     1  // Copyright (c) 2020-2022 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 pi
     6  
     7  import (
     8  	"encoding/base64"
     9  	"encoding/json"
    10  	"fmt"
    11  	"strings"
    12  	"time"
    13  
    14  	backend "github.com/decred/politeia/politeiad/backendv2"
    15  	"github.com/decred/politeia/politeiad/backendv2/tstorebe/plugins"
    16  	"github.com/decred/politeia/politeiad/plugins/comments"
    17  	"github.com/decred/politeia/politeiad/plugins/pi"
    18  	"github.com/decred/politeia/politeiad/plugins/ticketvote"
    19  	"github.com/decred/politeia/politeiad/plugins/usermd"
    20  	"github.com/decred/politeia/util"
    21  	"github.com/pkg/errors"
    22  )
    23  
    24  const (
    25  	// Accepted MIME types
    26  	mimeTypeText     = "text/plain"
    27  	mimeTypeTextUTF8 = "text/plain; charset=utf-8"
    28  	mimeTypePNG      = "image/png"
    29  )
    30  
    31  var (
    32  	// allowedTextFiles contains the filenames of the only text files
    33  	// that are allowed to be submitted as part of a proposal.
    34  	allowedTextFiles = map[string]struct{}{
    35  		pi.FileNameIndexFile:            {},
    36  		pi.FileNameProposalMetadata:     {},
    37  		ticketvote.FileNameVoteMetadata: {},
    38  	}
    39  )
    40  
    41  // hookNewRecordPre adds plugin specific validation onto the tstore backend
    42  // RecordNew method.
    43  func (p *piPlugin) hookNewRecordPre(payload string) error {
    44  	var nr plugins.HookNewRecordPre
    45  	err := json.Unmarshal([]byte(payload), &nr)
    46  	if err != nil {
    47  		return err
    48  	}
    49  
    50  	return p.proposalFilesVerify(nr.Files)
    51  }
    52  
    53  // hookEditRecordPre adds plugin specific validation onto the tstore backend
    54  // RecordEdit method.
    55  func (p *piPlugin) hookEditRecordPre(payload string) error {
    56  	var er plugins.HookEditRecord
    57  	err := json.Unmarshal([]byte(payload), &er)
    58  	if err != nil {
    59  		return err
    60  	}
    61  
    62  	// Verify proposal files
    63  	err = p.proposalFilesVerify(er.Files)
    64  	if err != nil {
    65  		return err
    66  	}
    67  
    68  	// Verify vote status. Edits are not allowed to be made once a vote
    69  	// has been authorized. This only needs to be checked for vetted
    70  	// records since you cannot authorize or start a ticket vote on an
    71  	// unvetted record.
    72  	if er.RecordMetadata.State == backend.StateVetted {
    73  		t, err := tokenDecode(er.RecordMetadata.Token)
    74  		if err != nil {
    75  			return err
    76  		}
    77  		s, err := p.voteSummary(t)
    78  		if err != nil {
    79  			return err
    80  		}
    81  		if s.Status != ticketvote.VoteStatusUnauthorized {
    82  			return backend.PluginError{
    83  				PluginID:  pi.PluginID,
    84  				ErrorCode: uint32(pi.ErrorCodeVoteStatusInvalid),
    85  				ErrorContext: fmt.Sprintf("vote status '%v' "+
    86  					"does not allow for proposal edits",
    87  					ticketvote.VoteStatuses[s.Status]),
    88  			}
    89  		}
    90  	}
    91  
    92  	return nil
    93  }
    94  
    95  // hookCommentNew adds pi specific validation onto the comments plugin New
    96  // command.
    97  func (p *piPlugin) hookCommentNew(token []byte, cmd, payload string) error {
    98  	return p.commentWritesAllowed(token, cmd, payload)
    99  }
   100  
   101  // hookCommentDel adds pi specific validation onto the comments plugin Del
   102  // command.
   103  func (p *piPlugin) hookCommentDel(token []byte, cmd, payload string) error {
   104  	return p.commentWritesAllowed(token, cmd, payload)
   105  }
   106  
   107  // hookCommentVote adds pi specific validation onto the comments plugin Vote
   108  // command.
   109  func (p *piPlugin) hookCommentVote(token []byte, cmd, payload string) error {
   110  	return p.commentWritesAllowed(token, cmd, payload)
   111  }
   112  
   113  // hookPluginPre extends plugin write commands from other plugins with pi
   114  // specific validation.
   115  func (p *piPlugin) hookPluginPre(payload string) error {
   116  	// Decode payload
   117  	var hpp plugins.HookPluginPre
   118  	err := json.Unmarshal([]byte(payload), &hpp)
   119  	if err != nil {
   120  		return err
   121  	}
   122  
   123  	// Call plugin hook
   124  	switch hpp.PluginID {
   125  	case comments.PluginID:
   126  		switch hpp.Cmd {
   127  		case comments.CmdNew:
   128  			return p.hookCommentNew(hpp.Token, hpp.Cmd, hpp.Payload)
   129  		case comments.CmdDel:
   130  			return p.hookCommentDel(hpp.Token, hpp.Cmd, hpp.Payload)
   131  		case comments.CmdVote:
   132  			return p.hookCommentVote(hpp.Token, hpp.Cmd, hpp.Payload)
   133  		}
   134  	}
   135  
   136  	return nil
   137  }
   138  
   139  // titleIsValid returns whether the provided title, which can be either a
   140  // proposal name or an author update title, matches the pi plugin title regex.
   141  func (p *piPlugin) titleIsValid(title string) bool {
   142  	return p.titleRegexp.MatchString(title)
   143  }
   144  
   145  // proposalStartDateIsValid returns whether the provided start date is valid.
   146  //
   147  // A valid start date of a proposal must be after the minimum start date
   148  // set by the proposalStartDateMin plugin setting.
   149  func (p *piPlugin) proposalStartDateIsValid(start int64) bool {
   150  	return start > time.Now().Unix()+p.proposalStartDateMin
   151  }
   152  
   153  // proposalEndDateIsValid returns whether the provided end date is valid.
   154  //
   155  // A valid end date must be after the start date and before the end of the
   156  // time interval set by the proposalEndDateMax plugin setting.
   157  func (p *piPlugin) proposalEndDateIsValid(start int64, end int64) bool {
   158  	return end > start &&
   159  		time.Now().Unix()+p.proposalEndDateMax > end
   160  }
   161  
   162  // proposalAmountIsValid returns whether the provided amount is in the range
   163  // defined by the proposalAmountMin & proposalAmountMax plugin settings.
   164  func (p *piPlugin) proposalAmountIsValid(amount uint64) bool {
   165  	return p.proposalAmountMin <= amount &&
   166  		p.proposalAmountMax >= amount
   167  }
   168  
   169  // proposalDomainIsValid returns whether the provided domain is
   170  // is a valid proposal domain.
   171  func (p *piPlugin) proposalDomainIsValid(domain string) bool {
   172  	_, found := p.proposalDomains[domain]
   173  	return found
   174  }
   175  
   176  // isRFP returns true if the given vote metadata contains the metadata for
   177  // an RFP.
   178  func isRFP(vm *ticketvote.VoteMetadata) bool {
   179  	return vm != nil && vm.LinkBy != 0
   180  }
   181  
   182  // proposalFilesVerify verifies the files adhere to all pi plugin setting
   183  // requirements. If this hook is being executed then the files have already
   184  // passed politeiad validation so we can assume that the file has a unique
   185  // name, a valid base64 payload, and that the file digest and MIME type are
   186  // correct.
   187  func (p *piPlugin) proposalFilesVerify(files []backend.File) error {
   188  	// Sanity check
   189  	if len(files) == 0 {
   190  		return errors.Errorf("no files found")
   191  	}
   192  
   193  	// Verify file types and sizes
   194  	var imagesCount uint32
   195  	for _, v := range files {
   196  		payload, err := base64.StdEncoding.DecodeString(v.Payload)
   197  		if err != nil {
   198  			return errors.Errorf("invalid base64 %v", v.Name)
   199  		}
   200  
   201  		// MIME type specific validation
   202  		switch v.MIME {
   203  		case mimeTypeText, mimeTypeTextUTF8:
   204  			// Verify text file is allowed
   205  			_, ok := allowedTextFiles[v.Name]
   206  			if !ok {
   207  				allowed := make([]string, 0, len(allowedTextFiles))
   208  				for name := range allowedTextFiles {
   209  					allowed = append(allowed, name)
   210  				}
   211  				return backend.PluginError{
   212  					PluginID:  pi.PluginID,
   213  					ErrorCode: uint32(pi.ErrorCodeTextFileNameInvalid),
   214  					ErrorContext: fmt.Sprintf("invalid text file name "+
   215  						"%v; allowed text file names are %v",
   216  						v.Name, strings.Join(allowed, ", ")),
   217  				}
   218  			}
   219  
   220  			// Verify text file size
   221  			if len(payload) > int(p.textFileSizeMax) {
   222  				return backend.PluginError{
   223  					PluginID:  pi.PluginID,
   224  					ErrorCode: uint32(pi.ErrorCodeTextFileSizeInvalid),
   225  					ErrorContext: fmt.Sprintf("file %v "+
   226  						"size %v exceeds max size %v",
   227  						v.Name, len(payload),
   228  						p.textFileSizeMax),
   229  				}
   230  			}
   231  
   232  		case mimeTypePNG:
   233  			imagesCount++
   234  
   235  			// Verify image file size
   236  			if len(payload) > int(p.imageFileSizeMax) {
   237  				return backend.PluginError{
   238  					PluginID:  pi.PluginID,
   239  					ErrorCode: uint32(pi.ErrorCodeImageFileSizeInvalid),
   240  					ErrorContext: fmt.Sprintf("image %v "+
   241  						"size %v exceeds max size %v",
   242  						v.Name, len(payload),
   243  						p.imageFileSizeMax),
   244  				}
   245  			}
   246  
   247  		default:
   248  			return errors.Errorf("invalid mime: %v", v.MIME)
   249  		}
   250  	}
   251  
   252  	// Verify that an index file is present
   253  	var found bool
   254  	for _, v := range files {
   255  		if v.Name == pi.FileNameIndexFile {
   256  			found = true
   257  			break
   258  		}
   259  	}
   260  	if !found {
   261  		return backend.PluginError{
   262  			PluginID:     pi.PluginID,
   263  			ErrorCode:    uint32(pi.ErrorCodeTextFileMissing),
   264  			ErrorContext: pi.FileNameIndexFile,
   265  		}
   266  	}
   267  
   268  	// Verify image file count is acceptable
   269  	if imagesCount > p.imageFileCountMax {
   270  		return backend.PluginError{
   271  			PluginID:  pi.PluginID,
   272  			ErrorCode: uint32(pi.ErrorCodeImageFileCountInvalid),
   273  			ErrorContext: fmt.Sprintf("got %v image files, max "+
   274  				"is %v", imagesCount, p.imageFileCountMax),
   275  		}
   276  	}
   277  
   278  	// Verify a proposal metadata has been included
   279  	pm, err := proposalMetadataDecode(files)
   280  	if err != nil {
   281  		return err
   282  	}
   283  	if pm == nil {
   284  		return backend.PluginError{
   285  			PluginID:     pi.PluginID,
   286  			ErrorCode:    uint32(pi.ErrorCodeTextFileMissing),
   287  			ErrorContext: pi.FileNameProposalMetadata,
   288  		}
   289  	}
   290  
   291  	// Validate vote & proposal metadata requirements
   292  	vm, err := voteMetadataDecode(files)
   293  	if err != nil {
   294  		return err
   295  	}
   296  	// In case of an RFP ensure irrelevant proposal metadata are not provided.
   297  	if isRFP(vm) {
   298  		switch {
   299  		case pm.Amount != 0:
   300  			return backend.PluginError{
   301  				PluginID:     pi.PluginID,
   302  				ErrorCode:    uint32(pi.ErrorCodeProposalAmountInvalid),
   303  				ErrorContext: "RFP metadata should not include an amount",
   304  			}
   305  		case pm.StartDate != 0:
   306  			return backend.PluginError{
   307  				PluginID:     pi.PluginID,
   308  				ErrorCode:    uint32(pi.ErrorCodeProposalStartDateInvalid),
   309  				ErrorContext: "RFP metadata should not include a start date",
   310  			}
   311  		case pm.EndDate != 0:
   312  			return backend.PluginError{
   313  				PluginID:     pi.PluginID,
   314  				ErrorCode:    uint32(pi.ErrorCodeProposalEndDateInvalid),
   315  				ErrorContext: "RFP metadata should not include an end date",
   316  			}
   317  		}
   318  	}
   319  
   320  	// Verify proposal name
   321  	if !p.titleIsValid(pm.Name) {
   322  		return backend.PluginError{
   323  			PluginID:     pi.PluginID,
   324  			ErrorCode:    uint32(pi.ErrorCodeTitleInvalid),
   325  			ErrorContext: p.titleRegexp.String(),
   326  		}
   327  	}
   328  
   329  	// Validate proposal domain.
   330  	if !p.proposalDomainIsValid(pm.Domain) {
   331  		return backend.PluginError{
   332  			PluginID:  pi.PluginID,
   333  			ErrorCode: uint32(pi.ErrorCodeProposalDomainInvalid),
   334  			ErrorContext: fmt.Sprintf("got %v domain, "+
   335  				"supported domains are: %v", pm.Domain, p.proposalDomains),
   336  		}
   337  	}
   338  
   339  	// Ensure legacy token is not set during normal proposal submissions
   340  	if pm.LegacyToken != "" {
   341  		return backend.PluginError{
   342  			PluginID:  pi.PluginID,
   343  			ErrorCode: uint32(pi.ErrorCodeLegacyTokenNotAllowed),
   344  		}
   345  	}
   346  
   347  	// If not RFP validate rest of proposal metadata fields
   348  	if !isRFP(vm) {
   349  		// Validate proposal start date.
   350  		if !p.proposalStartDateIsValid(pm.StartDate) {
   351  			return backend.PluginError{
   352  				PluginID:  pi.PluginID,
   353  				ErrorCode: uint32(pi.ErrorCodeProposalStartDateInvalid),
   354  				ErrorContext: fmt.Sprintf("start date (%v) must be after %v",
   355  					pm.StartDate, time.Now().Unix()-p.proposalStartDateMin),
   356  			}
   357  		}
   358  
   359  		// Validate proposal end date.
   360  		if !p.proposalEndDateIsValid(pm.StartDate, pm.EndDate) {
   361  			return backend.PluginError{
   362  				PluginID:  pi.PluginID,
   363  				ErrorCode: uint32(pi.ErrorCodeProposalEndDateInvalid),
   364  				ErrorContext: fmt.Sprintf("end date (%v) must be before %v",
   365  					pm.EndDate, time.Now().Unix()+p.proposalEndDateMax),
   366  			}
   367  		}
   368  
   369  		// Validate proposal amount.
   370  		if !p.proposalAmountIsValid(pm.Amount) {
   371  			return backend.PluginError{
   372  				PluginID:  pi.PluginID,
   373  				ErrorCode: uint32(pi.ErrorCodeProposalAmountInvalid),
   374  				ErrorContext: fmt.Sprintf("got %v amount, min is %v, "+
   375  					"max is %v", pm.Amount, p.proposalAmountMin, p.proposalAmountMax),
   376  			}
   377  		}
   378  	}
   379  
   380  	return nil
   381  }
   382  
   383  // voteSummary requests the vote summary from the ticketvote plugin for a
   384  // record.
   385  func (p *piPlugin) voteSummary(token []byte) (*ticketvote.SummaryReply, error) {
   386  	reply, err := p.backend.PluginRead(token, ticketvote.PluginID,
   387  		ticketvote.CmdSummary, "")
   388  	if err != nil {
   389  		return nil, err
   390  	}
   391  	var sr ticketvote.SummaryReply
   392  	err = json.Unmarshal([]byte(reply), &sr)
   393  	if err != nil {
   394  		return nil, err
   395  	}
   396  	return &sr, nil
   397  }
   398  
   399  // comments requests all comments on a record from the comments plugin.
   400  func (p *piPlugin) comments(token []byte) (*comments.GetAllReply, error) {
   401  	reply, err := p.backend.PluginRead(token, comments.PluginID,
   402  		comments.CmdGetAll, "")
   403  	if err != nil {
   404  		return nil, err
   405  	}
   406  	var gar comments.GetAllReply
   407  	err = json.Unmarshal([]byte(reply), &gar)
   408  	if err != nil {
   409  		return nil, err
   410  	}
   411  	return &gar, nil
   412  }
   413  
   414  // isInCommentTree returns whether the leafID is part of the provided comment
   415  // tree. A leaf is considered to be part of the tree if the leaf is a child of
   416  // the root or the leaf references the root itself.
   417  func isInCommentTree(rootID, leafID uint32, cs []comments.Comment) bool {
   418  	if leafID == rootID {
   419  		return true
   420  	}
   421  	// Convert comments slice to a map
   422  	commentsMap := make(map[uint32]comments.Comment, len(cs))
   423  	for _, c := range cs {
   424  		commentsMap[c.CommentID] = c
   425  	}
   426  
   427  	// Start with the provided comment leaf and traverse the comment tree up
   428  	// until either the provided root ID is found or we reach the tree head. The
   429  	// tree head will have a comment ID of 0.
   430  	current := commentsMap[leafID]
   431  	for current.ParentID != 0 {
   432  		// Check if next parent in the tree is the rootID.
   433  		if current.ParentID == rootID {
   434  			return true
   435  		}
   436  		leafID = current.ParentID
   437  		current = commentsMap[leafID]
   438  	}
   439  	return false
   440  }
   441  
   442  // latestAuthorUpdate gets the latest author update on a record, if
   443  // the record has no author update it returns nil.
   444  func latestAuthorUpdate(token []byte, cs []comments.Comment) *comments.Comment {
   445  	var latestAuthorUpdate comments.Comment
   446  	for _, c := range cs {
   447  		if c.ExtraDataHint != pi.ProposalUpdateHint {
   448  			continue
   449  		}
   450  		if c.Timestamp > latestAuthorUpdate.Timestamp {
   451  			latestAuthorUpdate = c
   452  		}
   453  	}
   454  	return &latestAuthorUpdate
   455  }
   456  
   457  // recordAuthor returns the author's userID of the record associated with
   458  // the provided token.
   459  func (p *piPlugin) recordAuthor(token []byte) (string, error) {
   460  	reply, err := p.backend.PluginRead(token, usermd.PluginID,
   461  		usermd.CmdAuthor, "")
   462  	if err != nil {
   463  		return "", err
   464  	}
   465  	var ar usermd.AuthorReply
   466  	err = json.Unmarshal([]byte(reply), &ar)
   467  	if err != nil {
   468  		return "", err
   469  	}
   470  	return ar.UserID, nil
   471  }
   472  
   473  // commentVoteAllowedOnApprovedProposal verifies that the given comment
   474  // vote is allowed on a proposal which finished voting and it's vote was
   475  // approved.
   476  func (p *piPlugin) commentVoteAllowedOnApprovedProposal(token []byte, payload string, latestAuthorUpdate comments.Comment, cs []comments.Comment) error {
   477  	// Decode payload
   478  	var v comments.Vote
   479  	err := json.Unmarshal([]byte(payload), &v)
   480  	if err != nil {
   481  		return err
   482  	}
   483  
   484  	if !isInCommentTree(latestAuthorUpdate.CommentID, v.CommentID, cs) {
   485  		return backend.PluginError{
   486  			PluginID:  pi.PluginID,
   487  			ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed),
   488  			ErrorContext: "votes are only allowed on the author's " +
   489  				"most recent update thread",
   490  		}
   491  	}
   492  
   493  	return nil
   494  }
   495  
   496  // isValidAuthorUpdate returns whether the given new comment is a valid author
   497  // update.
   498  //
   499  // The comment must include proper proposal update metadata and the comment
   500  // must be submitted by the proposal author for it to be considered a valid
   501  // author update.
   502  func (p *piPlugin) isValidAuthorUpdate(token []byte, n comments.New) error {
   503  	// Get the proposal author. The proposal author
   504  	// and the comment author must be the same user.
   505  	recordAuthorID, err := p.recordAuthor(token)
   506  	if err != nil {
   507  		return err
   508  	}
   509  	if recordAuthorID != n.UserID {
   510  		return backend.PluginError{
   511  			PluginID:     pi.PluginID,
   512  			ErrorCode:    uint32(pi.ErrorCodeCommentWriteNotAllowed),
   513  			ErrorContext: "user is not the proposal author",
   514  		}
   515  	}
   516  
   517  	// Verify extra data fields
   518  	if n.ExtraDataHint != pi.ProposalUpdateHint {
   519  		return backend.PluginError{
   520  			PluginID:  pi.PluginID,
   521  			ErrorCode: uint32(pi.ErrorCodeExtraDataHintInvalid),
   522  			ErrorContext: fmt.Sprintf("got %v, want %v",
   523  				n.ExtraDataHint, pi.ProposalUpdateHint),
   524  		}
   525  	}
   526  	var pum pi.ProposalUpdateMetadata
   527  	err = json.Unmarshal([]byte(n.ExtraData), &pum)
   528  	if err != nil {
   529  		return backend.PluginError{
   530  			PluginID:  pi.PluginID,
   531  			ErrorCode: uint32(pi.ErrorCodeExtraDataInvalid),
   532  		}
   533  	}
   534  
   535  	// Verify update title
   536  	if !p.titleIsValid(pum.Title) {
   537  		return backend.PluginError{
   538  			PluginID:     pi.PluginID,
   539  			ErrorCode:    uint32(pi.ErrorCodeTitleInvalid),
   540  			ErrorContext: p.titleRegexp.String(),
   541  		}
   542  	}
   543  
   544  	// The comment is a valid author update.
   545  	return nil
   546  }
   547  
   548  // commentNewAllowedOnApprovedProposal verifies that the given new comment
   549  // is allowed on a proposal which finished voting and it's vote was approved.
   550  func (p *piPlugin) commentNewAllowedOnApprovedProposal(token []byte, payload string, latestAuthorUpdate comments.Comment, cs []comments.Comment) error {
   551  	// Decode payload
   552  	var n comments.New
   553  	err := json.Unmarshal([]byte(payload), &n)
   554  	if err != nil {
   555  		return err
   556  	}
   557  
   558  	// A new comment on an approved proposal must either be an update
   559  	// from the author (parent ID will be 0) or a reply to the latest
   560  	// author update.
   561  	isUpdateReply := isInCommentTree(latestAuthorUpdate.CommentID,
   562  		n.ParentID, cs)
   563  	switch {
   564  	case n.ParentID == 0:
   565  		// This might be an update from the author.
   566  		return p.isValidAuthorUpdate(token, n)
   567  
   568  	case isUpdateReply:
   569  		// This is a reply to the latest update. This is allowed.
   570  		return nil
   571  
   572  	case !isUpdateReply:
   573  		// New comment is a reply, but is not a reply to the latest update. This
   574  		// is not allowed.
   575  		return backend.PluginError{
   576  			PluginID:  pi.PluginID,
   577  			ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed),
   578  			ErrorContext: "comment replies are only allowed on " +
   579  				"the author's most recent update thread",
   580  		}
   581  
   582  	default:
   583  		// This should not happen
   584  		return errors.Errorf("unknown comment write state")
   585  	}
   586  }
   587  
   588  // writesAllowedOnApprovedProposal verifies that the given comment write is
   589  // allowed on a proposal which finished voting and it's vote was approved. This
   590  // includes both comments and comment votes.
   591  func (p *piPlugin) writesAllowedOnApprovedProposal(token []byte, cmd, payload string) error {
   592  	// Get billing status to determine whether to allow author updates
   593  	// or not.
   594  	var bsc *pi.BillingStatusChange
   595  	bscs, err := p.billingStatusChanges(token)
   596  	if err != nil {
   597  		return err
   598  	}
   599  	if len(bscs) > 0 {
   600  		// Get latest billing status change
   601  		bsc = &bscs[len(bscs)-1]
   602  		if bsc.Status == pi.BillingStatusClosed ||
   603  			bsc.Status == pi.BillingStatusCompleted {
   604  			// If billing status is set to closed or completed, comment writes
   605  			// are not allowed.
   606  			return backend.PluginError{
   607  				PluginID:  pi.PluginID,
   608  				ErrorCode: uint32(pi.ErrorCodeBillingStatusInvalid),
   609  				ErrorContext: "billing status is set to closed/completed;" +
   610  					" proposal is locked",
   611  			}
   612  		}
   613  	}
   614  
   615  	// Get latest proposal author update
   616  	gar, err := p.comments(token)
   617  	if err != nil {
   618  		return err
   619  	}
   620  	latestAuthorUpdate := latestAuthorUpdate(token, gar.Comments)
   621  
   622  	switch cmd {
   623  	// If the user is submitting a new comment then it must be either a new
   624  	// author update or a comment on the latest author update thread.
   625  	case comments.CmdNew:
   626  		return p.commentNewAllowedOnApprovedProposal(token, payload,
   627  			*latestAuthorUpdate, gar.Comments)
   628  
   629  	// If the user is voting on a comment then it must be on one of the latest
   630  	// author update thread comments.
   631  	case comments.CmdVote:
   632  		return p.commentVoteAllowedOnApprovedProposal(token, payload,
   633  			*latestAuthorUpdate, gar.Comments)
   634  
   635  	}
   636  
   637  	return nil
   638  }
   639  
   640  // commentWritesAllowed verifies that a proposal has a vote status that allows
   641  // comment writes to be made to the proposal. This includes both comments and
   642  // comment votes.
   643  //
   644  // Once a proposal vote has finished, all existing comment threads are locked.
   645  //
   646  // When a proposal author wants to give an update on their **approved**
   647  // proposal they can start a new comment thread.
   648  //
   649  // The author is the only user that will have the ability to
   650  // start a new comment thread once the voting period has finished.
   651  //
   652  // Each update must have an author provided title.
   653  //
   654  // Anyone can reply to any comments in the thread and can cast
   655  // upvotes/downvotes for any comments in the thread.
   656  //
   657  // The comment thread will remain open until either the author starts a new
   658  // update thread or an admin marks the proposal as closed/completed.
   659  func (p *piPlugin) commentWritesAllowed(token []byte, cmd, payload string) error {
   660  	// Get record state
   661  	r, err := p.recordAbridged(token)
   662  	if err != nil {
   663  		return err
   664  	}
   665  	state := r.RecordMetadata.State
   666  
   667  	switch state {
   668  	case backend.StateUnvetted:
   669  		// Comment writes are allowed
   670  		return nil
   671  	case backend.StateVetted:
   672  		// Comment writes on vetted proposals depends on the
   673  		// proposal vote status. Continue to the vote status
   674  		// validation below.
   675  	default:
   676  		return errors.Errorf("unknown state: %v", state)
   677  	}
   678  
   679  	// Validate vote status
   680  	vs, err := p.voteSummary(token)
   681  	if err != nil {
   682  		return err
   683  	}
   684  	switch vs.Status {
   685  	case ticketvote.VoteStatusUnauthorized, ticketvote.VoteStatusAuthorized,
   686  		ticketvote.VoteStatusStarted:
   687  		// Comment writes are allowed on these vote statuses
   688  		return nil
   689  
   690  	case ticketvote.VoteStatusApproved:
   691  		return p.writesAllowedOnApprovedProposal(token, cmd, payload)
   692  
   693  	default:
   694  		// Vote status does not allow writes
   695  		return backend.PluginError{
   696  			PluginID:     pi.PluginID,
   697  			ErrorCode:    uint32(pi.ErrorCodeCommentWriteNotAllowed),
   698  			ErrorContext: "vote has ended; comments are locked",
   699  		}
   700  	}
   701  }
   702  
   703  // tokenDecode returns the decoded censorship token. An error will be returned
   704  // if the token is not a full length token.
   705  func tokenDecode(token string) ([]byte, error) {
   706  	return util.TokenDecode(util.TokenTypeTstore, token)
   707  }
   708  
   709  // proposalMetadataDecode decodes and returns the ProposalMetadata from the
   710  // provided backend files. If a ProposalMetadata is not found, nil is returned.
   711  func proposalMetadataDecode(files []backend.File) (*pi.ProposalMetadata, error) {
   712  	var propMD *pi.ProposalMetadata
   713  	for _, v := range files {
   714  		if v.Name != pi.FileNameProposalMetadata {
   715  			continue
   716  		}
   717  		b, err := base64.StdEncoding.DecodeString(v.Payload)
   718  		if err != nil {
   719  			return nil, err
   720  		}
   721  		var m pi.ProposalMetadata
   722  		err = json.Unmarshal(b, &m)
   723  		if err != nil {
   724  			return nil, err
   725  		}
   726  		propMD = &m
   727  		break
   728  	}
   729  	return propMD, nil
   730  }
   731  
   732  // voteMetadataDecode decodes and returns the VoteMetadata from the
   733  // provided backend files. If a VoteMetadata is not found, nil is returned.
   734  func voteMetadataDecode(files []backend.File) (*ticketvote.VoteMetadata, error) {
   735  	var voteMD *ticketvote.VoteMetadata
   736  	for _, v := range files {
   737  		if v.Name != ticketvote.FileNameVoteMetadata {
   738  			continue
   739  		}
   740  		b, err := base64.StdEncoding.DecodeString(v.Payload)
   741  		if err != nil {
   742  			return nil, err
   743  		}
   744  		var m ticketvote.VoteMetadata
   745  		err = json.Unmarshal(b, &m)
   746  		if err != nil {
   747  			return nil, err
   748  		}
   749  		voteMD = &m
   750  		break
   751  	}
   752  	return voteMD, nil
   753  }