github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/comments/cmds.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 comments
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/base64"
    10  	"encoding/hex"
    11  	"encoding/json"
    12  	"fmt"
    13  	"sort"
    14  	"strconv"
    15  	"time"
    16  
    17  	backend "github.com/decred/politeia/politeiad/backendv2"
    18  	"github.com/decred/politeia/politeiad/backendv2/tstorebe/store"
    19  	"github.com/decred/politeia/politeiad/plugins/comments"
    20  	"github.com/decred/politeia/util"
    21  	"github.com/pkg/errors"
    22  )
    23  
    24  const (
    25  	pluginID = comments.PluginID
    26  
    27  	// Blob entry data descriptors
    28  	dataDescriptorCommentAdd  = pluginID + "-add-v1"
    29  	dataDescriptorCommentDel  = pluginID + "-del-v1"
    30  	dataDescriptorCommentVote = pluginID + "-vote-v1"
    31  )
    32  
    33  // commentAddSave saves a CommentAdd to the backend.
    34  func (p *commentsPlugin) commentAddSave(token []byte, ca comments.CommentAdd) ([]byte, error) {
    35  	be, err := convertBlobEntryFromCommentAdd(ca)
    36  	if err != nil {
    37  		return nil, err
    38  	}
    39  	d, err := hex.DecodeString(be.Digest)
    40  	if err != nil {
    41  		return nil, err
    42  	}
    43  	err = p.tstore.BlobSave(token, *be)
    44  	if err != nil {
    45  		return nil, err
    46  	}
    47  	return d, nil
    48  }
    49  
    50  // commentAdds returns a commentAdd for each of the provided digests. A digest
    51  // refers to the blob entry digest, which is used as the key when retrieving
    52  // the blob entry from tstore.
    53  //
    54  // This function will return the comment adds in the same order that they are
    55  // requested in, i.e. the order of the digests slice. An error is returned
    56  // if a blob entry is not found for one or more of the provided digests.
    57  func (p *commentsPlugin) commentAdds(token []byte, digests [][]byte) ([]comments.CommentAdd, error) {
    58  	// Retrieve blobs
    59  	blobs, err := p.tstore.Blobs(token, digests)
    60  	if err != nil {
    61  		return nil, err
    62  	}
    63  	if len(blobs) != len(digests) {
    64  		notFound := make([]string, 0, len(blobs))
    65  		for _, v := range digests {
    66  			m := hex.EncodeToString(v)
    67  			_, ok := blobs[m]
    68  			if !ok {
    69  				notFound = append(notFound, m)
    70  			}
    71  		}
    72  		return nil, fmt.Errorf("blobs not found: %v", notFound)
    73  	}
    74  
    75  	// Decode blobs
    76  	adds := make([]comments.CommentAdd, 0, len(blobs))
    77  	for _, digest := range digests {
    78  		d := hex.EncodeToString(digest)
    79  		c, err := convertCommentAddFromBlobEntry(blobs[d])
    80  		if err != nil {
    81  			return nil, err
    82  		}
    83  		adds = append(adds, *c)
    84  	}
    85  
    86  	return adds, nil
    87  }
    88  
    89  // commentDelSave saves a CommentDel to the backend.
    90  func (p *commentsPlugin) commentDelSave(token []byte, cd comments.CommentDel) ([]byte, error) {
    91  	be, err := convertBlobEntryFromCommentDel(cd)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  	d, err := hex.DecodeString(be.Digest)
    96  	if err != nil {
    97  		return nil, err
    98  	}
    99  	err = p.tstore.BlobSave(token, *be)
   100  	if err != nil {
   101  		return nil, err
   102  	}
   103  	return d, nil
   104  }
   105  
   106  // commentDels returns a commentDel for each of the provided digests. A digest
   107  // refers to the blob entry digest, which is used as the key when retrieving
   108  // the blob entry from tstore.
   109  //
   110  // This function will return the comment dels in the same order that they are
   111  // requested in, i.e. the order of the digests slice. An error is returned
   112  // if a blob entry is not found for one or more of the provided digests.
   113  func (p *commentsPlugin) commentDels(token []byte, digests [][]byte) ([]comments.CommentDel, error) {
   114  	// Retrieve blobs
   115  	blobs, err := p.tstore.Blobs(token, digests)
   116  	if err != nil {
   117  		return nil, err
   118  	}
   119  	if len(blobs) != len(digests) {
   120  		notFound := make([]string, 0, len(blobs))
   121  		for _, v := range digests {
   122  			m := hex.EncodeToString(v)
   123  			_, ok := blobs[m]
   124  			if !ok {
   125  				notFound = append(notFound, m)
   126  			}
   127  		}
   128  		return nil, fmt.Errorf("blobs not found: %v", notFound)
   129  	}
   130  
   131  	// Decode blobs
   132  	dels := make([]comments.CommentDel, 0, len(blobs))
   133  	for _, digest := range digests {
   134  		d := hex.EncodeToString(digest)
   135  		del, err := convertCommentDelFromBlobEntry(blobs[d])
   136  		if err != nil {
   137  			return nil, err
   138  		}
   139  		dels = append(dels, *del)
   140  	}
   141  
   142  	return dels, nil
   143  }
   144  
   145  // commentVoteSave saves a CommentVote to the backend.
   146  func (p *commentsPlugin) commentVoteSave(token []byte, cv comments.CommentVote) ([]byte, error) {
   147  	be, err := convertBlobEntryFromCommentVote(cv)
   148  	if err != nil {
   149  		return nil, err
   150  	}
   151  	d, err := hex.DecodeString(be.Digest)
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  	err = p.tstore.BlobSave(token, *be)
   156  	if err != nil {
   157  		return nil, err
   158  	}
   159  	return d, nil
   160  }
   161  
   162  // commentVotes returns a commentVote for each of the provided digests. A
   163  // digest refers to the blob entry digest, which is used as the key when
   164  // retrieving the blob entry from tstore.
   165  //
   166  // This function will return the comment votes in the same order that they are
   167  // requested in, i.e. the order of the digests slice. An error is returned
   168  // if a blob entry is not found for one or more of the provided digests.
   169  func (p *commentsPlugin) commentVotes(token []byte, digests [][]byte) ([]comments.CommentVote, error) {
   170  	// Retrieve blobs
   171  	blobs, err := p.tstore.Blobs(token, digests)
   172  	if err != nil {
   173  		return nil, err
   174  	}
   175  	if len(blobs) != len(digests) {
   176  		notFound := make([]string, 0, len(blobs))
   177  		for _, v := range digests {
   178  			m := hex.EncodeToString(v)
   179  			_, ok := blobs[m]
   180  			if !ok {
   181  				notFound = append(notFound, m)
   182  			}
   183  		}
   184  		return nil, fmt.Errorf("blobs not found: %v", notFound)
   185  	}
   186  
   187  	// Decode blobs
   188  	votes := make([]comments.CommentVote, 0, len(blobs))
   189  	for _, digest := range digests {
   190  		d := hex.EncodeToString(digest)
   191  		c, err := convertCommentVoteFromBlobEntry(blobs[d])
   192  		if err != nil {
   193  			return nil, err
   194  		}
   195  		votes = append(votes, *c)
   196  	}
   197  
   198  	return votes, nil
   199  }
   200  
   201  // comments returns the most recent version of the specified comments. Deleted
   202  // comments are returned with limited data. If a comment is not found for a
   203  // provided comment IDs, the comment ID is excluded from the returned map. An
   204  // error will not be returned. It is the responsibility of the caller to ensure
   205  // a comment is returned for each of the provided comment IDs.
   206  func (p *commentsPlugin) comments(token []byte, ridx recordIndex, commentIDs []uint32) (map[uint32]comments.Comment, error) {
   207  	// Aggregate the digests for all records that need to be looked up.
   208  	// If a comment has been deleted then the only record that will
   209  	// still exist is the comment del record. If the comment has not
   210  	// been deleted then the comment add record will need to be
   211  	// retrieved for the latest version of the comment.
   212  	var (
   213  		digestAdds = make([][]byte, 0, len(commentIDs))
   214  		digestDels = make([][]byte, 0, len(commentIDs))
   215  	)
   216  	for _, v := range commentIDs {
   217  		cidx, ok := ridx.Comments[v]
   218  		if !ok {
   219  			// Comment does not exist
   220  			continue
   221  		}
   222  
   223  		// Comment del record
   224  		if cidx.Del != nil {
   225  			digestDels = append(digestDels, cidx.Del)
   226  			continue
   227  		}
   228  
   229  		// Comment add record
   230  		version := commentVersionLatest(cidx)
   231  		digestAdds = append(digestAdds, cidx.Adds[version])
   232  	}
   233  
   234  	// Get comment add records
   235  	adds, err := p.commentAdds(token, digestAdds)
   236  	if err != nil {
   237  		return nil, errors.Errorf("commentAdds: %v", err)
   238  	}
   239  	if len(adds) != len(digestAdds) {
   240  		return nil, errors.Errorf("wrong comment adds count; got %v, want %v",
   241  			len(adds), len(digestAdds))
   242  	}
   243  
   244  	// Get comment del records
   245  	dels, err := p.commentDels(token, digestDels)
   246  	if err != nil {
   247  		return nil, errors.Errorf("commentDels: %v", err)
   248  	}
   249  	if len(dels) != len(digestDels) {
   250  		return nil, errors.Errorf("wrong comment dels count; got %v, want %v",
   251  			len(dels), len(digestDels))
   252  	}
   253  
   254  	// Prepare comments
   255  	cs := make(map[uint32]comments.Comment, len(commentIDs))
   256  	for _, v := range adds {
   257  		c := convertCommentFromCommentAdd(v)
   258  		cidx, ok := ridx.Comments[c.CommentID]
   259  		if !ok {
   260  			return nil, errors.Errorf("comment index not found %v", c.CommentID)
   261  		}
   262  		c.Downvotes, c.Upvotes = voteScore(cidx)
   263  		// Populate creation timestamp
   264  		c.CreatedAt, err = p.commentCreationTimestamp(c, cidx)
   265  		if err != nil {
   266  			return nil, err
   267  		}
   268  
   269  		cs[v.CommentID] = c
   270  	}
   271  	for _, v := range dels {
   272  		c := convertCommentFromCommentDel(v)
   273  		cs[v.CommentID] = c
   274  	}
   275  
   276  	return cs, nil
   277  }
   278  
   279  // commentCreationTimestamp accepts the latest version of a comment with the
   280  // comment index , and it returns the comment's creation timestamp.
   281  func (p *commentsPlugin) commentCreationTimestamp(c comments.Comment, cidx commentIndex) (int64, error) {
   282  	// If comment was not edited, then the comment creation timestamp is
   283  	// equal to the first version's timestamp.
   284  	if c.Version == 1 {
   285  		return c.Timestamp, nil
   286  	}
   287  
   288  	// If comment was edited, we need to get the first version of the
   289  	// comment in order to determine the creation timestamp.
   290  	b, err := tokenDecode(c.Token)
   291  	if err != nil {
   292  		return 0, err
   293  	}
   294  	cf, err := p.commentFirstVersion(b, c.CommentID, cidx)
   295  	if err != nil {
   296  		return 0, err
   297  	}
   298  
   299  	return cf.Timestamp, nil
   300  }
   301  
   302  // comment returns the latest version of a comment.
   303  func (p *commentsPlugin) comment(token []byte, ridx recordIndex, commentID uint32) (*comments.Comment, error) {
   304  	cs, err := p.comments(token, ridx, []uint32{commentID})
   305  	if err != nil {
   306  		return nil, fmt.Errorf("comments: %v", err)
   307  	}
   308  	c, ok := cs[commentID]
   309  	if !ok {
   310  		return nil, fmt.Errorf("comment not found")
   311  	}
   312  	return &c, nil
   313  }
   314  
   315  // timestamp returns the timestamp for a blob entry digest.
   316  func (p *commentsPlugin) timestamp(token []byte, digest []byte) (*comments.Timestamp, error) {
   317  	// Get timestamp
   318  	t, err := p.tstore.Timestamp(token, digest)
   319  	if err != nil {
   320  		return nil, err
   321  	}
   322  
   323  	// Convert response
   324  	proofs := make([]comments.Proof, 0, len(t.Proofs))
   325  	for _, v := range t.Proofs {
   326  		proofs = append(proofs, comments.Proof{
   327  			Type:       v.Type,
   328  			Digest:     v.Digest,
   329  			MerkleRoot: v.MerkleRoot,
   330  			MerklePath: v.MerklePath,
   331  			ExtraData:  v.ExtraData,
   332  		})
   333  	}
   334  	return &comments.Timestamp{
   335  		Data:       t.Data,
   336  		Digest:     t.Digest,
   337  		TxID:       t.TxID,
   338  		MerkleRoot: t.MerkleRoot,
   339  		Proofs:     proofs,
   340  	}, nil
   341  }
   342  
   343  // commentTimestamps returns the CommentTimestamp for each of the provided
   344  // comment IDs.
   345  func (p *commentsPlugin) commentTimestamps(token []byte, commentIDs []uint32, includeVotes bool) (*comments.TimestampsReply, error) {
   346  	// Verify there is work to do
   347  	if len(commentIDs) == 0 {
   348  		return &comments.TimestampsReply{
   349  			Comments: map[uint32]comments.CommentTimestamp{},
   350  		}, nil
   351  	}
   352  
   353  	// Look for final timestamps in the key-value store. Caching final timestamps
   354  	// is necessary to improve the performance which is proportional to the tree
   355  	// size.
   356  	cts, err := p.cachedTimestamps(token, commentIDs)
   357  	if err != nil {
   358  		return nil, err
   359  	}
   360  
   361  	// Get record state
   362  	state, err := p.tstore.RecordState(token)
   363  	if err != nil {
   364  		return nil, err
   365  	}
   366  
   367  	// Get record index
   368  	ridx, err := p.recordIndex(token, state)
   369  	if err != nil {
   370  		return nil, err
   371  	}
   372  
   373  	// Get timestamps for each comment ID
   374  	r := make(map[uint32]comments.CommentTimestamp, len(commentIDs))
   375  	for _, cid := range commentIDs {
   376  		cidx, ok := ridx.Comments[cid]
   377  		if !ok {
   378  			// Comment ID does not exist. Skip it.
   379  			continue
   380  		}
   381  
   382  		// Get cached comment timestamp associated with current comment ID if one
   383  		// exists, nil otherwise.
   384  		ct := cts[cid]
   385  
   386  		// Get comment add timestamps
   387  		adds := make([]comments.Timestamp, 0, len(cidx.Adds))
   388  		for _, v := range cidx.Adds {
   389  			// Check if the comment add digest timestamp is final and already exists
   390  			// in cache.
   391  			var t *comments.Timestamp
   392  			if ct != nil {
   393  				for _, at := range ct.Adds {
   394  					if at.Digest == hex.EncodeToString(v) {
   395  						t = &at
   396  					}
   397  				}
   398  			}
   399  			if t != nil {
   400  				// Cached timestamp found, collect it and continue to next comment
   401  				// add digest.
   402  				adds = append(adds, *t)
   403  				continue
   404  			}
   405  
   406  			// Comment add digest was not found in cache, get timestamp
   407  			ts, err := p.timestamp(token, v)
   408  			if err != nil {
   409  				return nil, err
   410  			}
   411  			adds = append(adds, *ts)
   412  		}
   413  
   414  		// Get comment del timestamps. This will only exist if the
   415  		// comment has been deleted.
   416  		var del *comments.Timestamp
   417  		if cidx.Del != nil {
   418  			// Check if the comment del digest timestamp is final and already exists
   419  			// in cache.
   420  			var t *comments.Timestamp
   421  			if ct != nil {
   422  				if ct.Del != nil && ct.Del.Digest == hex.EncodeToString(cidx.Del) {
   423  					t = ct.Del
   424  				}
   425  			}
   426  
   427  			switch {
   428  			case t != nil:
   429  				// Comment del timestamp found in cache, collect it
   430  				del = t
   431  
   432  			case t == nil:
   433  				// Comment del timestamp was not found in cache, get timestamp
   434  				ts, err := p.timestamp(token, cidx.Del)
   435  				if err != nil {
   436  					return nil, err
   437  				}
   438  				del = ts
   439  			}
   440  		}
   441  
   442  		// Get comment vote timestamps
   443  		var votes []comments.Timestamp
   444  		if includeVotes {
   445  			votes = make([]comments.Timestamp, 0, len(cidx.Votes))
   446  			for _, voteIdxs := range cidx.Votes {
   447  				for _, v := range voteIdxs {
   448  					// Check if the comment vote digest timestamp is final and already
   449  					// exists in cache.
   450  					var t *comments.Timestamp
   451  					if ct != nil {
   452  						for _, vt := range ct.Votes {
   453  							if vt.Digest == hex.EncodeToString(v.Digest) {
   454  								t = &vt
   455  							}
   456  						}
   457  					}
   458  					if t != nil {
   459  						// Cached timestamp found, collect it and continue to next comment
   460  						// vote digest.
   461  						votes = append(votes, *t)
   462  						continue
   463  					}
   464  
   465  					// Comment vote digest was not found in cache, get timestamp
   466  					ts, err := p.timestamp(token, v.Digest)
   467  					if err != nil {
   468  						return nil, err
   469  					}
   470  					votes = append(votes, *ts)
   471  				}
   472  			}
   473  		}
   474  
   475  		// Save timestamp
   476  		r[cid] = comments.CommentTimestamp{
   477  			Adds:  adds,
   478  			Del:   del,
   479  			Votes: votes,
   480  		}
   481  	}
   482  
   483  	// Cache final timestamps
   484  	err = p.cacheFinalTimestamps(token, r)
   485  	if err != nil {
   486  		return nil, err
   487  	}
   488  
   489  	return &comments.TimestampsReply{
   490  		Comments: r,
   491  	}, nil
   492  }
   493  
   494  // voteScore returns the total number of downvotes and upvotes, respectively,
   495  // for a comment.
   496  func voteScore(cidx commentIndex) (uint64, uint64) {
   497  	// Find the vote score by replaying all existing votes from all
   498  	// users. The net effect of a new vote on a comment score depends
   499  	// on the previous vote from that uuid. Example, a user upvotes a
   500  	// comment that they have already upvoted, the resulting vote score
   501  	// is 0 due to the second upvote removing the original upvote.
   502  	var upvotes uint64
   503  	var downvotes uint64
   504  	for _, votes := range cidx.Votes {
   505  		// Calculate the vote score that this user is contributing. This
   506  		// can only ever be -1, 0, or 1.
   507  		var score int64
   508  		for _, v := range votes {
   509  			vote := int64(v.Vote)
   510  			switch {
   511  			case score == 0:
   512  				// No previous vote. New vote becomes the score.
   513  				score = vote
   514  
   515  			case score == vote:
   516  				// New vote is the same as the previous vote. The vote gets
   517  				// removed from the score, making the score 0.
   518  				score = 0
   519  
   520  			case score != vote:
   521  				// New vote is different than the previous vote. New vote
   522  				// becomes the score.
   523  				score = vote
   524  			}
   525  		}
   526  
   527  		// Add the net result of all votes from this user to the totals.
   528  		switch score {
   529  		case 0:
   530  			// Nothing to do
   531  		case -1:
   532  			downvotes++
   533  		case 1:
   534  			upvotes++
   535  		default:
   536  			// Should not be possible
   537  			panic(fmt.Errorf("unexpected vote score %v", score))
   538  		}
   539  	}
   540  
   541  	return downvotes, upvotes
   542  }
   543  
   544  // cmdNew creates a new comment.
   545  func (p *commentsPlugin) cmdNew(token []byte, payload string) (string, error) {
   546  	// Decode payload
   547  	var n comments.New
   548  	err := json.Unmarshal([]byte(payload), &n)
   549  	if err != nil {
   550  		return "", err
   551  	}
   552  
   553  	// Verify token
   554  	err = tokenVerify(token, n.Token)
   555  	if err != nil {
   556  		return "", err
   557  	}
   558  
   559  	// Ensure no extra data provided if not allowed
   560  	err = p.verifyExtraData(n.ExtraData, n.ExtraDataHint)
   561  	if err != nil {
   562  		return "", err
   563  	}
   564  
   565  	// Verify signature
   566  	msg := strconv.FormatUint(uint64(n.State), 10) + n.Token +
   567  		strconv.FormatUint(uint64(n.ParentID), 10) + n.Comment +
   568  		n.ExtraData + n.ExtraDataHint
   569  	err = util.VerifySignature(n.Signature, n.PublicKey, msg)
   570  	if err != nil {
   571  		return "", convertSignatureError(err)
   572  	}
   573  
   574  	// Verify comment
   575  	if len(n.Comment) == 0 {
   576  		return "", backend.PluginError{
   577  			PluginID:  comments.PluginID,
   578  			ErrorCode: uint32(comments.ErrorCodeEmptyComment),
   579  		}
   580  	}
   581  	if len(n.Comment) > int(p.commentLengthMax) {
   582  		return "", backend.PluginError{
   583  			PluginID:  comments.PluginID,
   584  			ErrorCode: uint32(comments.ErrorCodeMaxLengthExceeded),
   585  			ErrorContext: fmt.Sprintf("max length is %v characters",
   586  				p.commentLengthMax),
   587  		}
   588  	}
   589  
   590  	// Verify record state
   591  	state, err := p.tstore.RecordState(token)
   592  	if err != nil {
   593  		return "", err
   594  	}
   595  	if uint32(n.State) != uint32(state) {
   596  		return "", backend.PluginError{
   597  			PluginID:     comments.PluginID,
   598  			ErrorCode:    uint32(comments.ErrorCodeRecordStateInvalid),
   599  			ErrorContext: fmt.Sprintf("got %v, want %v", n.State, state),
   600  		}
   601  	}
   602  
   603  	// Get record index
   604  	ridx, err := p.recordIndex(token, state)
   605  	if err != nil {
   606  		return "", err
   607  	}
   608  
   609  	// Verify parent comment exists if set. A parent ID of 0 means that
   610  	// this is a base level comment, not a reply to another comment.
   611  	if n.ParentID > 0 && !commentExists(*ridx, n.ParentID) {
   612  		return "", backend.PluginError{
   613  			PluginID:     comments.PluginID,
   614  			ErrorCode:    uint32(comments.ErrorCodeParentIDInvalid),
   615  			ErrorContext: "parent ID comment not found",
   616  		}
   617  	}
   618  
   619  	// Setup comment
   620  	receipt := p.identity.SignMessage([]byte(n.Signature))
   621  	ca := comments.CommentAdd{
   622  		UserID:        n.UserID,
   623  		State:         n.State,
   624  		Token:         n.Token,
   625  		ParentID:      n.ParentID,
   626  		Comment:       n.Comment,
   627  		PublicKey:     n.PublicKey,
   628  		Signature:     n.Signature,
   629  		CommentID:     commentIDLatest(*ridx) + 1,
   630  		Version:       1,
   631  		Timestamp:     time.Now().Unix(),
   632  		Receipt:       hex.EncodeToString(receipt[:]),
   633  		ExtraData:     n.ExtraData,
   634  		ExtraDataHint: n.ExtraDataHint,
   635  	}
   636  
   637  	// Save comment
   638  	digest, err := p.commentAddSave(token, ca)
   639  	if err != nil {
   640  		return "", err
   641  	}
   642  
   643  	// Update the index
   644  	ridx.Comments[ca.CommentID] = commentIndex{
   645  		Adds: map[uint32][]byte{
   646  			1: digest,
   647  		},
   648  		Del:   nil,
   649  		Votes: make(map[string][]voteIndex),
   650  	}
   651  
   652  	// Save the updated index
   653  	p.recordIndexSave(token, state, *ridx)
   654  
   655  	log.Debugf("Comment saved to record %v comment ID %v",
   656  		ca.Token, ca.CommentID)
   657  
   658  	// Return new comment
   659  	c, err := p.comment(token, *ridx, ca.CommentID)
   660  	if err != nil {
   661  		return "", fmt.Errorf("comment %x %v: %v", token, ca.CommentID, err)
   662  	}
   663  
   664  	// Prepare reply
   665  	nr := comments.NewReply{
   666  		Comment: *c,
   667  	}
   668  	reply, err := json.Marshal(nr)
   669  	if err != nil {
   670  		return "", err
   671  	}
   672  
   673  	return string(reply), nil
   674  }
   675  
   676  // cmdEdit edits an existing comment.
   677  func (p *commentsPlugin) cmdEdit(token []byte, payload string) (string, error) {
   678  	// Check if comment edits are allowed
   679  	if !p.allowEdits {
   680  		return "", backend.PluginError{
   681  			PluginID:     comments.PluginID,
   682  			ErrorCode:    uint32(comments.ErrorCodeEditNotAllowed),
   683  			ErrorContext: "comments plugin setting 'allowedits' is off",
   684  		}
   685  	}
   686  
   687  	// Decode payload
   688  	var e comments.Edit
   689  	err := json.Unmarshal([]byte(payload), &e)
   690  	if err != nil {
   691  		return "", err
   692  	}
   693  
   694  	// Verify token
   695  	err = tokenVerify(token, e.Token)
   696  	if err != nil {
   697  		return "", err
   698  	}
   699  
   700  	// Ensure no extra data provided if not allowed
   701  	err = p.verifyExtraData(e.ExtraData, e.ExtraDataHint)
   702  	if err != nil {
   703  		return "", err
   704  	}
   705  
   706  	// Verify signature
   707  	msg := strconv.FormatUint(uint64(e.State), 10) + e.Token +
   708  		strconv.FormatUint(uint64(e.ParentID), 10) +
   709  		strconv.FormatUint(uint64(e.CommentID), 10) +
   710  		e.Comment + e.ExtraData + e.ExtraDataHint
   711  	err = util.VerifySignature(e.Signature, e.PublicKey, msg)
   712  	if err != nil {
   713  		return "", convertSignatureError(err)
   714  	}
   715  
   716  	// Verify comment
   717  	if len(e.Comment) == 0 {
   718  		return "", backend.PluginError{
   719  			PluginID:  comments.PluginID,
   720  			ErrorCode: uint32(comments.ErrorCodeEmptyComment),
   721  		}
   722  	}
   723  	if len(e.Comment) > int(p.commentLengthMax) {
   724  		return "", backend.PluginError{
   725  			PluginID:  comments.PluginID,
   726  			ErrorCode: uint32(comments.ErrorCodeMaxLengthExceeded),
   727  			ErrorContext: fmt.Sprintf("max length is %v characters",
   728  				p.commentLengthMax),
   729  		}
   730  	}
   731  
   732  	// Verify record state
   733  	state, err := p.tstore.RecordState(token)
   734  	if err != nil {
   735  		return "", err
   736  	}
   737  	if uint32(e.State) != uint32(state) {
   738  		return "", backend.PluginError{
   739  			PluginID:     comments.PluginID,
   740  			ErrorCode:    uint32(comments.ErrorCodeRecordStateInvalid),
   741  			ErrorContext: fmt.Sprintf("got %v, want %v", e.State, state),
   742  		}
   743  	}
   744  
   745  	// Get record index
   746  	ridx, err := p.recordIndex(token, state)
   747  	if err != nil {
   748  		return "", err
   749  	}
   750  
   751  	cidx, ok := ridx.Comments[e.CommentID]
   752  	if !ok {
   753  		// Comment not found
   754  		return "", backend.PluginError{
   755  			PluginID:  comments.PluginID,
   756  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
   757  		}
   758  	}
   759  
   760  	// Get first version of the comment
   761  	cf, err := p.commentFirstVersion(token, e.CommentID, cidx)
   762  	if err != nil {
   763  		return "", err
   764  	}
   765  
   766  	// Comment edits are allowed only during the timeframe
   767  	// set by the editPeriod plugin setting.
   768  	if time.Now().Unix() > cf.Timestamp+int64(p.editPeriod) {
   769  		return "", backend.PluginError{
   770  			PluginID:     comments.PluginID,
   771  			ErrorCode:    uint32(comments.ErrorCodeEditNotAllowed),
   772  			ErrorContext: "comment edits timeframe has expired",
   773  		}
   774  	}
   775  
   776  	// Get the existing comment
   777  	cs, err := p.comments(token, *ridx, []uint32{e.CommentID})
   778  	if err != nil {
   779  		return "", fmt.Errorf("comments %v: %v", e.CommentID, err)
   780  	}
   781  	existing, ok := cs[e.CommentID]
   782  	if !ok {
   783  		return "", backend.PluginError{
   784  			PluginID:  comments.PluginID,
   785  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
   786  		}
   787  	}
   788  
   789  	// Verify the user ID
   790  	if e.UserID != existing.UserID {
   791  		return "", backend.PluginError{
   792  			PluginID:  comments.PluginID,
   793  			ErrorCode: uint32(comments.ErrorCodeUserUnauthorized),
   794  		}
   795  	}
   796  
   797  	// Verify the parent ID
   798  	if e.ParentID != existing.ParentID {
   799  		return "", backend.PluginError{
   800  			PluginID:  comments.PluginID,
   801  			ErrorCode: uint32(comments.ErrorCodeParentIDInvalid),
   802  			ErrorContext: fmt.Sprintf("parent id cannot change; got %v, want %v",
   803  				e.ParentID, existing.ParentID),
   804  		}
   805  	}
   806  
   807  	// Verify extra data hint. This doesn't really belong here, and should be
   808  	// left up to the application plugin (i.e. the pi plugin) to decide. It was
   809  	// put here to prevent application plugin from needing to pull the prior
   810  	// version of the comment, which is expensive since it causes a tlog tree
   811  	// retrieval.
   812  	if e.ExtraDataHint != existing.ExtraDataHint {
   813  		return "", backend.PluginError{
   814  			PluginID:     comments.PluginID,
   815  			ErrorCode:    uint32(comments.ErrorCodeEditNotAllowed),
   816  			ErrorContext: "extra data hint edits are not allowed",
   817  		}
   818  	}
   819  
   820  	// Verify comment changes
   821  	if e.Comment == existing.Comment &&
   822  		e.ExtraData == existing.ExtraData {
   823  		return "", backend.PluginError{
   824  			PluginID:  comments.PluginID,
   825  			ErrorCode: uint32(comments.ErrorCodeNoChanges),
   826  		}
   827  	}
   828  
   829  	// Create a new comment version
   830  	receipt := p.identity.SignMessage([]byte(e.Signature))
   831  	ca := comments.CommentAdd{
   832  		UserID:        e.UserID,
   833  		State:         e.State,
   834  		Token:         e.Token,
   835  		ParentID:      e.ParentID,
   836  		Comment:       e.Comment,
   837  		PublicKey:     e.PublicKey,
   838  		Signature:     e.Signature,
   839  		CommentID:     e.CommentID,
   840  		Version:       existing.Version + 1,
   841  		Timestamp:     time.Now().Unix(),
   842  		Receipt:       hex.EncodeToString(receipt[:]),
   843  		ExtraData:     e.ExtraData,
   844  		ExtraDataHint: e.ExtraDataHint,
   845  	}
   846  
   847  	// Save comment
   848  	digest, err := p.commentAddSave(token, ca)
   849  	if err != nil {
   850  		return "", err
   851  	}
   852  
   853  	// Update the index
   854  	ridx.Comments[ca.CommentID].Adds[ca.Version] = digest
   855  
   856  	// Save the updated index
   857  	p.recordIndexSave(token, state, *ridx)
   858  
   859  	log.Debugf("Comment edited on record %v comment ID %v",
   860  		ca.Token, ca.CommentID)
   861  
   862  	// Return updated comment
   863  	c, err := p.comment(token, *ridx, e.CommentID)
   864  	if err != nil {
   865  		return "", fmt.Errorf("comment %x %v: %v", token, e.CommentID, err)
   866  	}
   867  
   868  	// Prepare reply
   869  	er := comments.EditReply{
   870  		Comment: *c,
   871  	}
   872  	reply, err := json.Marshal(er)
   873  	if err != nil {
   874  		return "", err
   875  	}
   876  
   877  	return string(reply), nil
   878  }
   879  
   880  // commentFirstVersion returns the first version of the specified comment. The
   881  // returned comment does not include the vote score.
   882  func (p *commentsPlugin) commentFirstVersion(token []byte, commentID uint32, cidx commentIndex) (*comments.Comment, error) {
   883  	// First version comment add digest
   884  	digest := cidx.Adds[1]
   885  
   886  	// Comment add record
   887  	adds, err := p.commentAdds(token, [][]byte{digest})
   888  	if err != nil {
   889  		return nil, errors.Errorf("commentAdds: %v", err)
   890  	}
   891  	if len(adds) != 1 {
   892  		return nil, errors.Errorf("wrong comment adds count; got %v, want %v",
   893  			len(adds), 1)
   894  	}
   895  
   896  	// Convert comment add
   897  	c := convertCommentFromCommentAdd(adds[0])
   898  
   899  	return &c, nil
   900  }
   901  
   902  // verifyExtraData ensures no extra data provided if it's not allowed.
   903  func (p *commentsPlugin) verifyExtraData(extraData, extraDataHint string) error {
   904  	if !p.allowExtraData && (extraData != "" || extraDataHint != "") {
   905  		return backend.PluginError{
   906  			PluginID:  comments.PluginID,
   907  			ErrorCode: uint32(comments.ErrorCodeExtraDataNotAllowed),
   908  		}
   909  	}
   910  	return nil
   911  }
   912  
   913  // cmdDel deletes a comment.
   914  func (p *commentsPlugin) cmdDel(token []byte, payload string) (string, error) {
   915  	// Decode payload
   916  	var d comments.Del
   917  	err := json.Unmarshal([]byte(payload), &d)
   918  	if err != nil {
   919  		return "", err
   920  	}
   921  
   922  	// Verify token
   923  	err = tokenVerify(token, d.Token)
   924  	if err != nil {
   925  		return "", err
   926  	}
   927  
   928  	// Verify signature
   929  	msg := strconv.FormatUint(uint64(d.State), 10) + d.Token +
   930  		strconv.FormatUint(uint64(d.CommentID), 10) + d.Reason
   931  	err = util.VerifySignature(d.Signature, d.PublicKey, msg)
   932  	if err != nil {
   933  		return "", convertSignatureError(err)
   934  	}
   935  
   936  	// Verify record state
   937  	state, err := p.tstore.RecordState(token)
   938  	if err != nil {
   939  		return "", err
   940  	}
   941  	if uint32(d.State) != uint32(state) {
   942  		return "", backend.PluginError{
   943  			PluginID:     comments.PluginID,
   944  			ErrorCode:    uint32(comments.ErrorCodeRecordStateInvalid),
   945  			ErrorContext: fmt.Sprintf("got %v, want %v", d.State, state),
   946  		}
   947  	}
   948  
   949  	// Get record index
   950  	ridx, err := p.recordIndex(token, state)
   951  	if err != nil {
   952  		return "", err
   953  	}
   954  
   955  	// Get the existing comment
   956  	cs, err := p.comments(token, *ridx, []uint32{d.CommentID})
   957  	if err != nil {
   958  		return "", fmt.Errorf("comments %v: %v", d.CommentID, err)
   959  	}
   960  	existing, ok := cs[d.CommentID]
   961  	if !ok {
   962  		return "", backend.PluginError{
   963  			PluginID:  comments.PluginID,
   964  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
   965  		}
   966  	}
   967  
   968  	// Prepare comment delete
   969  	receipt := p.identity.SignMessage([]byte(d.Signature))
   970  	cd := comments.CommentDel{
   971  		Token:     d.Token,
   972  		State:     d.State,
   973  		CommentID: d.CommentID,
   974  		Reason:    d.Reason,
   975  		PublicKey: d.PublicKey,
   976  		Signature: d.Signature,
   977  		ParentID:  existing.ParentID,
   978  		UserID:    existing.UserID,
   979  		Timestamp: time.Now().Unix(),
   980  		Receipt:   hex.EncodeToString(receipt[:]),
   981  	}
   982  
   983  	// Save comment del
   984  	digest, err := p.commentDelSave(token, cd)
   985  	if err != nil {
   986  		return "", err
   987  	}
   988  
   989  	// Update the index
   990  	cidx, ok := ridx.Comments[d.CommentID]
   991  	if !ok {
   992  		// Should not be possible. The cache is not coherent.
   993  		panic(fmt.Sprintf("comment not found in index: %v", d.CommentID))
   994  	}
   995  	cidx.Del = digest
   996  	ridx.Comments[d.CommentID] = cidx
   997  
   998  	// Svae the updated index
   999  	p.recordIndexSave(token, state, *ridx)
  1000  
  1001  	// Delete all comment versions. A comment is considered deleted
  1002  	// once the CommenDel record has been saved. If attempts to
  1003  	// actually delete the blobs fails, simply log the error and
  1004  	// continue command execution. The period fsck will clean this up
  1005  	// next time it is run.
  1006  	digests := make([][]byte, 0, len(cidx.Adds))
  1007  	for _, v := range cidx.Adds {
  1008  		digests = append(digests, v)
  1009  	}
  1010  	err = p.tstore.BlobsDel(token, digests)
  1011  	if err != nil {
  1012  		log.Errorf("comments cmdDel %x: BlobsDel %x: %v ",
  1013  			token, digests, err)
  1014  	}
  1015  
  1016  	// Return updated comment
  1017  	c, err := p.comment(token, *ridx, d.CommentID)
  1018  	if err != nil {
  1019  		return "", fmt.Errorf("comment %v: %v", d.CommentID, err)
  1020  	}
  1021  
  1022  	// Prepare reply
  1023  	dr := comments.DelReply{
  1024  		Comment: *c,
  1025  	}
  1026  	reply, err := json.Marshal(dr)
  1027  	if err != nil {
  1028  		return "", err
  1029  	}
  1030  
  1031  	return string(reply), nil
  1032  }
  1033  
  1034  // cmdVote casts a upvote/downvote for a comment.
  1035  func (p *commentsPlugin) cmdVote(token []byte, payload string) (string, error) {
  1036  	// Decode payload
  1037  	var v comments.Vote
  1038  	err := json.Unmarshal([]byte(payload), &v)
  1039  	if err != nil {
  1040  		return "", err
  1041  	}
  1042  
  1043  	// Verify token
  1044  	err = tokenVerify(token, v.Token)
  1045  	if err != nil {
  1046  		return "", err
  1047  	}
  1048  
  1049  	// Verify vote
  1050  	switch v.Vote {
  1051  	case comments.VoteDownvote, comments.VoteUpvote:
  1052  		// These are allowed
  1053  	default:
  1054  		return "", backend.PluginError{
  1055  			PluginID:  comments.PluginID,
  1056  			ErrorCode: uint32(comments.ErrorCodeVoteInvalid),
  1057  		}
  1058  	}
  1059  
  1060  	// Verify signature
  1061  	msg := strconv.FormatUint(uint64(v.State), 10) + v.Token +
  1062  		strconv.FormatUint(uint64(v.CommentID), 10) +
  1063  		strconv.FormatInt(int64(v.Vote), 10)
  1064  	err = util.VerifySignature(v.Signature, v.PublicKey, msg)
  1065  	if err != nil {
  1066  		return "", convertSignatureError(err)
  1067  	}
  1068  
  1069  	// Verify record state
  1070  	state, err := p.tstore.RecordState(token)
  1071  	if err != nil {
  1072  		return "", err
  1073  	}
  1074  	if uint32(v.State) != uint32(state) {
  1075  		return "", backend.PluginError{
  1076  			PluginID:     comments.PluginID,
  1077  			ErrorCode:    uint32(comments.ErrorCodeRecordStateInvalid),
  1078  			ErrorContext: fmt.Sprintf("got %v, want %v", v.State, state),
  1079  		}
  1080  	}
  1081  
  1082  	// Get record index
  1083  	ridx, err := p.recordIndex(token, state)
  1084  	if err != nil {
  1085  		return "", err
  1086  	}
  1087  
  1088  	// Verify comment exists
  1089  	cidx, ok := ridx.Comments[v.CommentID]
  1090  	if !ok {
  1091  		return "", backend.PluginError{
  1092  			PluginID:  comments.PluginID,
  1093  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
  1094  		}
  1095  	}
  1096  
  1097  	// Verify user has not exceeded max allowed vote changes
  1098  	if len(cidx.Votes[v.UserID]) > int(p.voteChangesMax) {
  1099  		return "", backend.PluginError{
  1100  			PluginID:  comments.PluginID,
  1101  			ErrorCode: uint32(comments.ErrorCodeVoteChangesMaxExceeded),
  1102  		}
  1103  	}
  1104  
  1105  	// Verify user is not voting on their own comment
  1106  	cs, err := p.comments(token, *ridx, []uint32{v.CommentID})
  1107  	if err != nil {
  1108  		return "", fmt.Errorf("comments %v: %v", v.CommentID, err)
  1109  	}
  1110  	c, ok := cs[v.CommentID]
  1111  	if !ok {
  1112  		return "", fmt.Errorf("comment not found %v", v.CommentID)
  1113  	}
  1114  	if v.UserID == c.UserID {
  1115  		return "", backend.PluginError{
  1116  			PluginID:     comments.PluginID,
  1117  			ErrorCode:    uint32(comments.ErrorCodeVoteInvalid),
  1118  			ErrorContext: "user cannot vote on their own comment",
  1119  		}
  1120  	}
  1121  
  1122  	// Prepare comment vote
  1123  	receipt := p.identity.SignMessage([]byte(v.Signature))
  1124  	cv := comments.CommentVote{
  1125  		UserID:    v.UserID,
  1126  		State:     v.State,
  1127  		Token:     v.Token,
  1128  		CommentID: v.CommentID,
  1129  		Vote:      v.Vote,
  1130  		PublicKey: v.PublicKey,
  1131  		Signature: v.Signature,
  1132  		Timestamp: time.Now().Unix(),
  1133  		Receipt:   hex.EncodeToString(receipt[:]),
  1134  	}
  1135  
  1136  	// Save comment vote
  1137  	digest, err := p.commentVoteSave(token, cv)
  1138  	if err != nil {
  1139  		return "", err
  1140  	}
  1141  
  1142  	// Add vote to the comment index
  1143  	votes, ok := cidx.Votes[cv.UserID]
  1144  	if !ok {
  1145  		votes = make([]voteIndex, 0, 1)
  1146  	}
  1147  	votes = append(votes, voteIndex{
  1148  		Vote:   cv.Vote,
  1149  		Digest: digest,
  1150  	})
  1151  	cidx.Votes[cv.UserID] = votes
  1152  	ridx.Comments[cv.CommentID] = cidx
  1153  
  1154  	// Save the updated index
  1155  	p.recordIndexSave(token, state, *ridx)
  1156  
  1157  	// Calculate the new vote scores
  1158  	downvotes, upvotes := voteScore(cidx)
  1159  
  1160  	// Prepare reply
  1161  	vr := comments.VoteReply{
  1162  		Downvotes: downvotes,
  1163  		Upvotes:   upvotes,
  1164  		Timestamp: cv.Timestamp,
  1165  		Receipt:   cv.Receipt,
  1166  	}
  1167  	reply, err := json.Marshal(vr)
  1168  	if err != nil {
  1169  		return "", err
  1170  	}
  1171  
  1172  	return string(reply), nil
  1173  }
  1174  
  1175  // cmdGet retrieves a batch of specified comments. The most recent version of
  1176  // each comment is returned.
  1177  func (p *commentsPlugin) cmdGet(token []byte, payload string) (string, error) {
  1178  	// Decode payload
  1179  	var g comments.Get
  1180  	err := json.Unmarshal([]byte(payload), &g)
  1181  	if err != nil {
  1182  		return "", err
  1183  	}
  1184  
  1185  	// Get record state
  1186  	state, err := p.tstore.RecordState(token)
  1187  	if err != nil {
  1188  		return "", err
  1189  	}
  1190  
  1191  	// Get record index
  1192  	ridx, err := p.recordIndex(token, state)
  1193  	if err != nil {
  1194  		return "", err
  1195  	}
  1196  
  1197  	// Get comments
  1198  	cs, err := p.comments(token, *ridx, g.CommentIDs)
  1199  	if err != nil {
  1200  		return "", fmt.Errorf("comments: %v", err)
  1201  	}
  1202  
  1203  	// Prepare reply
  1204  	gr := comments.GetReply{
  1205  		Comments: cs,
  1206  	}
  1207  	reply, err := json.Marshal(gr)
  1208  	if err != nil {
  1209  		return "", err
  1210  	}
  1211  
  1212  	return string(reply), nil
  1213  }
  1214  
  1215  // cmdGetAll retrieves all comments for a record. The latest version of each
  1216  // comment is returned.
  1217  func (p *commentsPlugin) cmdGetAll(token []byte) (string, error) {
  1218  	// Get record state
  1219  	state, err := p.tstore.RecordState(token)
  1220  	if err != nil {
  1221  		return "", err
  1222  	}
  1223  
  1224  	// Compile comment IDs
  1225  	ridx, err := p.recordIndex(token, state)
  1226  	if err != nil {
  1227  		return "", err
  1228  	}
  1229  	commentIDs := make([]uint32, 0, len(ridx.Comments))
  1230  	for k := range ridx.Comments {
  1231  		commentIDs = append(commentIDs, k)
  1232  	}
  1233  
  1234  	// Get comments
  1235  	c, err := p.comments(token, *ridx, commentIDs)
  1236  	if err != nil {
  1237  		return "", fmt.Errorf("comments: %v", err)
  1238  	}
  1239  
  1240  	// Convert comments from a map to a slice
  1241  	cs := make([]comments.Comment, 0, len(c))
  1242  	for _, v := range c {
  1243  		cs = append(cs, v)
  1244  	}
  1245  
  1246  	// Order comments by comment ID
  1247  	sort.SliceStable(cs, func(i, j int) bool {
  1248  		return cs[i].CommentID < cs[j].CommentID
  1249  	})
  1250  
  1251  	// Prepare reply
  1252  	gar := comments.GetAllReply{
  1253  		Comments: cs,
  1254  	}
  1255  	reply, err := json.Marshal(gar)
  1256  	if err != nil {
  1257  		return "", err
  1258  	}
  1259  
  1260  	return string(reply), nil
  1261  }
  1262  
  1263  // cmdGetVersion retrieves the specified version of a comment.
  1264  func (p *commentsPlugin) cmdGetVersion(token []byte, payload string) (string, error) {
  1265  	// Decode payload
  1266  	var gv comments.GetVersion
  1267  	err := json.Unmarshal([]byte(payload), &gv)
  1268  	if err != nil {
  1269  		return "", err
  1270  	}
  1271  
  1272  	// Get record state
  1273  	state, err := p.tstore.RecordState(token)
  1274  	if err != nil {
  1275  		return "", err
  1276  	}
  1277  
  1278  	// Get record index
  1279  	ridx, err := p.recordIndex(token, state)
  1280  	if err != nil {
  1281  		return "", err
  1282  	}
  1283  
  1284  	// Verify comment exists
  1285  	cidx, ok := ridx.Comments[gv.CommentID]
  1286  	if !ok {
  1287  		return "", backend.PluginError{
  1288  			PluginID:  comments.PluginID,
  1289  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
  1290  		}
  1291  	}
  1292  	if cidx.Del != nil {
  1293  		return "", backend.PluginError{
  1294  			PluginID:     comments.PluginID,
  1295  			ErrorCode:    uint32(comments.ErrorCodeCommentNotFound),
  1296  			ErrorContext: "comment has been deleted",
  1297  		}
  1298  	}
  1299  	digest, ok := cidx.Adds[gv.Version]
  1300  	if !ok {
  1301  		return "", backend.PluginError{
  1302  			PluginID:  comments.PluginID,
  1303  			ErrorCode: uint32(comments.ErrorCodeCommentNotFound),
  1304  			ErrorContext: fmt.Sprintf("comment %v does not have version %v",
  1305  				gv.CommentID, gv.Version),
  1306  		}
  1307  	}
  1308  
  1309  	// Get comment add record
  1310  	adds, err := p.commentAdds(token, [][]byte{digest})
  1311  	if err != nil {
  1312  		return "", fmt.Errorf("commentAdds: %v", err)
  1313  	}
  1314  	if len(adds) != 1 {
  1315  		return "", fmt.Errorf("wrong comment adds count; got %v, want 1",
  1316  			len(adds))
  1317  	}
  1318  
  1319  	// Convert to a comment
  1320  	c := convertCommentFromCommentAdd(adds[0])
  1321  	c.Downvotes, c.Upvotes = voteScore(cidx)
  1322  
  1323  	// Prepare reply
  1324  	gvr := comments.GetVersionReply{
  1325  		Comment: c,
  1326  	}
  1327  	reply, err := json.Marshal(gvr)
  1328  	if err != nil {
  1329  		return "", err
  1330  	}
  1331  
  1332  	return string(reply), nil
  1333  }
  1334  
  1335  // cmdCount retrieves the comments count for a record. The comments count is
  1336  // the number of comments that have been made on a record.
  1337  func (p *commentsPlugin) cmdCount(token []byte) (string, error) {
  1338  	// Get record state
  1339  	state, err := p.tstore.RecordState(token)
  1340  	if err != nil {
  1341  		return "", err
  1342  	}
  1343  
  1344  	// Get record index
  1345  	ridx, err := p.recordIndex(token, state)
  1346  	if err != nil {
  1347  		return "", err
  1348  	}
  1349  
  1350  	// Prepare reply
  1351  	cr := comments.CountReply{
  1352  		Count: uint32(len(ridx.Comments)),
  1353  	}
  1354  	reply, err := json.Marshal(cr)
  1355  	if err != nil {
  1356  		return "", err
  1357  	}
  1358  
  1359  	return string(reply), nil
  1360  }
  1361  
  1362  // cmdVotes retrieves the comment votes that meet the provided filtering
  1363  // criteria.
  1364  func (p *commentsPlugin) cmdVotes(token []byte, payload string) (string, error) {
  1365  	// Decode payload
  1366  	var v comments.Votes
  1367  	err := json.Unmarshal([]byte(payload), &v)
  1368  	if err != nil {
  1369  		return "", err
  1370  	}
  1371  
  1372  	// Get record state
  1373  	state, err := p.tstore.RecordState(token)
  1374  	if err != nil {
  1375  		return "", err
  1376  	}
  1377  
  1378  	// Get record index
  1379  	ridx, err := p.recordIndex(token, state)
  1380  	if err != nil {
  1381  		return "", err
  1382  	}
  1383  
  1384  	// Collect the requested page of comment vote digests
  1385  	digests := collectVoteDigestsPage(ridx.Comments, v.UserID, v.Page,
  1386  		p.votesPageSize)
  1387  
  1388  	// Lookup votes
  1389  	votes, err := p.commentVotes(token, digests)
  1390  	if err != nil {
  1391  		return "", fmt.Errorf("commentVotes: %v", err)
  1392  	}
  1393  
  1394  	// Sort comment votes by timestamp from newest to oldest.
  1395  	sort.SliceStable(votes, func(i, j int) bool {
  1396  		return votes[i].Timestamp > votes[j].Timestamp
  1397  	})
  1398  
  1399  	// Prepare reply
  1400  	vr := comments.VotesReply{
  1401  		Votes: votes,
  1402  	}
  1403  	reply, err := json.Marshal(vr)
  1404  	if err != nil {
  1405  		return "", err
  1406  	}
  1407  
  1408  	return string(reply), nil
  1409  }
  1410  
  1411  // collectVoteDigestsPage accepts a map of all comment indexes with a
  1412  // filtering criteria and it collects the requested page.
  1413  func collectVoteDigestsPage(commentIdxes map[uint32]commentIndex, userID string, page, pageSize uint32) [][]byte {
  1414  	// Default to first page if page is not provided
  1415  	if page == 0 {
  1416  		page = 1
  1417  	}
  1418  
  1419  	digests := make([][]byte, 0, pageSize)
  1420  	var (
  1421  		pageFirstIndex uint32 = (page - 1) * pageSize
  1422  		pageLastIndex  uint32 = page * pageSize
  1423  		idx            uint32 = 0
  1424  		filterByUserID        = userID != ""
  1425  	)
  1426  
  1427  	// Iterate over record index comments map deterministically; start from
  1428  	// comment id 1 upwards.
  1429  	for commentID := 1; commentID <= len(commentIdxes); commentID++ {
  1430  		cidx := commentIdxes[uint32(commentID)]
  1431  		switch {
  1432  		// User ID filtering criteria is applied. Collect the requested page of
  1433  		// the user's comment votes.
  1434  		case filterByUserID:
  1435  			voteIdxs, ok := cidx.Votes[userID]
  1436  			if !ok {
  1437  				// User has not cast any votes for this comment
  1438  				continue
  1439  			}
  1440  			for _, vidx := range voteIdxs {
  1441  				// Add digest if it's part of the requested page
  1442  				if isInPageRange(idx, pageFirstIndex, pageLastIndex) {
  1443  					digests = append(digests, vidx.Digest)
  1444  
  1445  					// If digests page is full, then we are done
  1446  					if len(digests) == int(pageSize) {
  1447  						return digests
  1448  					}
  1449  				}
  1450  				idx++
  1451  			}
  1452  
  1453  		// No filtering criteria is applied. The votes are indexed by user ID and
  1454  		// saved in a map. In order to return a page of votes in a deterministic
  1455  		// manner, the user IDs must first be sorted, then the pagination is
  1456  		// applied.
  1457  		default:
  1458  			userIDs := getSortedUserIDs(cidx.Votes)
  1459  			for _, userID := range userIDs {
  1460  				for _, vidx := range cidx.Votes[userID] {
  1461  					// Add digest if it's part of the requested page
  1462  					if isInPageRange(idx, pageFirstIndex, pageLastIndex) {
  1463  						digests = append(digests, vidx.Digest)
  1464  
  1465  						// If digests page is full, then we are done
  1466  						if len(digests) == int(pageSize) {
  1467  							return digests
  1468  						}
  1469  					}
  1470  					idx++
  1471  				}
  1472  			}
  1473  		}
  1474  	}
  1475  
  1476  	return digests
  1477  }
  1478  
  1479  // getSortedUserIDs accepts a map of comment vote indexes indexed by user IDs,
  1480  // it collects the keys, sorts them and finally returns them as sorted slice.
  1481  func getSortedUserIDs(m map[string][]voteIndex) []string {
  1482  	userIDs := make([]string, 0, len(m))
  1483  	for userID := range m {
  1484  		userIDs = append(userIDs, userID)
  1485  	}
  1486  
  1487  	// Sort keys
  1488  	sort.Strings(userIDs)
  1489  
  1490  	return userIDs
  1491  }
  1492  
  1493  // isInPageRange determines whether the given index is part of the given
  1494  // page range.
  1495  func isInPageRange(idx, pageFirstIndex, pageLastIndex uint32) bool {
  1496  	return idx >= pageFirstIndex && idx <= pageLastIndex
  1497  }
  1498  
  1499  // cmdTimestamps retrieves the timestamps for the comments of a record.
  1500  func (p *commentsPlugin) cmdTimestamps(token []byte, payload string) (string, error) {
  1501  	// Decode payload
  1502  	var t comments.Timestamps
  1503  	err := json.Unmarshal([]byte(payload), &t)
  1504  	if err != nil {
  1505  		return "", err
  1506  	}
  1507  
  1508  	// Get timestamps
  1509  	ctr, err := p.commentTimestamps(token, t.CommentIDs, t.IncludeVotes)
  1510  	if err != nil {
  1511  		return "", err
  1512  	}
  1513  
  1514  	// Prepare reply
  1515  	reply, err := json.Marshal(*ctr)
  1516  	if err != nil {
  1517  		return "", err
  1518  	}
  1519  
  1520  	return string(reply), nil
  1521  }
  1522  
  1523  // tokenDecode decodes a tstore token. It only accepts full length tokens.
  1524  func tokenDecode(token string) ([]byte, error) {
  1525  	return util.TokenDecode(util.TokenTypeTstore, token)
  1526  }
  1527  
  1528  // tokenVerify verifies that a token that is part of a plugin command payload
  1529  // is valid. This is applicable when a plugin command payload contains a
  1530  // signature that includes the record token. The token included in payload must
  1531  // be a valid, full length record token and it must match the token that was
  1532  // passed into the politeiad API for this plugin command, i.e. the token for
  1533  // the record that this plugin command is being executed on.
  1534  func tokenVerify(cmdToken []byte, payloadToken string) error {
  1535  	pt, err := tokenDecode(payloadToken)
  1536  	if err != nil {
  1537  		return backend.PluginError{
  1538  			PluginID:     comments.PluginID,
  1539  			ErrorCode:    uint32(comments.ErrorCodeTokenInvalid),
  1540  			ErrorContext: util.TokenRegexp(),
  1541  		}
  1542  	}
  1543  	if !bytes.Equal(cmdToken, pt) {
  1544  		return backend.PluginError{
  1545  			PluginID:  comments.PluginID,
  1546  			ErrorCode: uint32(comments.ErrorCodeTokenInvalid),
  1547  			ErrorContext: fmt.Sprintf("payload token does not match "+
  1548  				"command token: got %x, want %x", pt, cmdToken),
  1549  		}
  1550  	}
  1551  	return nil
  1552  }
  1553  
  1554  // commentVersionLatest returns the latest comment version.
  1555  func commentVersionLatest(cidx commentIndex) uint32 {
  1556  	var maxVersion uint32
  1557  	for version := range cidx.Adds {
  1558  		if version > maxVersion {
  1559  			maxVersion = version
  1560  		}
  1561  	}
  1562  	return maxVersion
  1563  }
  1564  
  1565  // commentExists returns whether the provided comment ID exists.
  1566  func commentExists(ridx recordIndex, commentID uint32) bool {
  1567  	_, ok := ridx.Comments[commentID]
  1568  	return ok
  1569  }
  1570  
  1571  // commentIDLatest returns the latest comment ID.
  1572  func commentIDLatest(idx recordIndex) uint32 {
  1573  	var maxID uint32
  1574  	for id := range idx.Comments {
  1575  		if id > maxID {
  1576  			maxID = id
  1577  		}
  1578  	}
  1579  	return maxID
  1580  }
  1581  
  1582  func convertCommentFromCommentAdd(ca comments.CommentAdd) comments.Comment {
  1583  	return comments.Comment{
  1584  		UserID:        ca.UserID,
  1585  		State:         ca.State,
  1586  		Token:         ca.Token,
  1587  		ParentID:      ca.ParentID,
  1588  		Comment:       ca.Comment,
  1589  		PublicKey:     ca.PublicKey,
  1590  		Signature:     ca.Signature,
  1591  		CommentID:     ca.CommentID,
  1592  		Version:       ca.Version,
  1593  		Timestamp:     ca.Timestamp,
  1594  		Receipt:       ca.Receipt,
  1595  		Downvotes:     0, // Not part of commentAdd data
  1596  		Upvotes:       0, // Not part of commentAdd data
  1597  		Deleted:       false,
  1598  		Reason:        "",
  1599  		ExtraData:     ca.ExtraData,
  1600  		ExtraDataHint: ca.ExtraDataHint,
  1601  	}
  1602  }
  1603  
  1604  func convertCommentFromCommentDel(cd comments.CommentDel) comments.Comment {
  1605  	// Score needs to be filled in separately
  1606  	return comments.Comment{
  1607  		UserID:    cd.UserID,
  1608  		State:     cd.State,
  1609  		Token:     cd.Token,
  1610  		ParentID:  cd.ParentID,
  1611  		Comment:   "",
  1612  		PublicKey: cd.PublicKey,
  1613  		Signature: cd.Signature,
  1614  		CommentID: cd.CommentID,
  1615  		Version:   0,
  1616  		Timestamp: cd.Timestamp,
  1617  		Receipt:   cd.Receipt,
  1618  		Downvotes: 0,
  1619  		Upvotes:   0,
  1620  		Deleted:   true,
  1621  		Reason:    cd.Reason,
  1622  	}
  1623  }
  1624  
  1625  func convertSignatureError(err error) backend.PluginError {
  1626  	var e util.SignatureError
  1627  	var s comments.ErrorCodeT
  1628  	if errors.As(err, &e) {
  1629  		switch e.ErrorCode {
  1630  		case util.ErrorStatusPublicKeyInvalid:
  1631  			s = comments.ErrorCodePublicKeyInvalid
  1632  		case util.ErrorStatusSignatureInvalid:
  1633  			s = comments.ErrorCodeSignatureInvalid
  1634  		}
  1635  	}
  1636  	return backend.PluginError{
  1637  		PluginID:     comments.PluginID,
  1638  		ErrorCode:    uint32(s),
  1639  		ErrorContext: e.ErrorContext,
  1640  	}
  1641  }
  1642  
  1643  func convertBlobEntryFromCommentAdd(c comments.CommentAdd) (*store.BlobEntry, error) {
  1644  	data, err := json.Marshal(c)
  1645  	if err != nil {
  1646  		return nil, err
  1647  	}
  1648  	hint, err := json.Marshal(
  1649  		store.DataDescriptor{
  1650  			Type:       store.DataTypeStructure,
  1651  			Descriptor: dataDescriptorCommentAdd,
  1652  		})
  1653  	if err != nil {
  1654  		return nil, err
  1655  	}
  1656  	be := store.NewBlobEntry(hint, data)
  1657  	return &be, nil
  1658  }
  1659  
  1660  func convertBlobEntryFromCommentDel(c comments.CommentDel) (*store.BlobEntry, error) {
  1661  	data, err := json.Marshal(c)
  1662  	if err != nil {
  1663  		return nil, err
  1664  	}
  1665  	hint, err := json.Marshal(
  1666  		store.DataDescriptor{
  1667  			Type:       store.DataTypeStructure,
  1668  			Descriptor: dataDescriptorCommentDel,
  1669  		})
  1670  	if err != nil {
  1671  		return nil, err
  1672  	}
  1673  	be := store.NewBlobEntry(hint, data)
  1674  	return &be, nil
  1675  }
  1676  
  1677  func convertBlobEntryFromCommentVote(c comments.CommentVote) (*store.BlobEntry, error) {
  1678  	data, err := json.Marshal(c)
  1679  	if err != nil {
  1680  		return nil, err
  1681  	}
  1682  	hint, err := json.Marshal(
  1683  		store.DataDescriptor{
  1684  			Type:       store.DataTypeStructure,
  1685  			Descriptor: dataDescriptorCommentVote,
  1686  		})
  1687  	if err != nil {
  1688  		return nil, err
  1689  	}
  1690  	be := store.NewBlobEntry(hint, data)
  1691  	return &be, nil
  1692  }
  1693  
  1694  func convertCommentAddFromBlobEntry(be store.BlobEntry) (*comments.CommentAdd, error) {
  1695  	// Decode and validate data hint
  1696  	b, err := base64.StdEncoding.DecodeString(be.DataHint)
  1697  	if err != nil {
  1698  		return nil, fmt.Errorf("decode DataHint: %v", err)
  1699  	}
  1700  	var dd store.DataDescriptor
  1701  	err = json.Unmarshal(b, &dd)
  1702  	if err != nil {
  1703  		return nil, fmt.Errorf("unmarshal DataHint: %v", err)
  1704  	}
  1705  	if dd.Descriptor != dataDescriptorCommentAdd {
  1706  		return nil, fmt.Errorf("unexpected data descriptor: got %v, want %v",
  1707  			dd.Descriptor, dataDescriptorCommentAdd)
  1708  	}
  1709  
  1710  	// Decode data
  1711  	b, err = base64.StdEncoding.DecodeString(be.Data)
  1712  	if err != nil {
  1713  		return nil, fmt.Errorf("decode Data: %v", err)
  1714  	}
  1715  	digest, err := hex.DecodeString(be.Digest)
  1716  	if err != nil {
  1717  		return nil, fmt.Errorf("decode digest: %v", err)
  1718  	}
  1719  	if !bytes.Equal(util.Digest(b), digest) {
  1720  		return nil, fmt.Errorf("data is not coherent; got %x, want %x",
  1721  			util.Digest(b), digest)
  1722  	}
  1723  	var c comments.CommentAdd
  1724  	err = json.Unmarshal(b, &c)
  1725  	if err != nil {
  1726  		return nil, fmt.Errorf("unmarshal CommentAdd: %v", err)
  1727  	}
  1728  
  1729  	return &c, nil
  1730  }
  1731  
  1732  func convertCommentDelFromBlobEntry(be store.BlobEntry) (*comments.CommentDel, error) {
  1733  	// Decode and validate data hint
  1734  	b, err := base64.StdEncoding.DecodeString(be.DataHint)
  1735  	if err != nil {
  1736  		return nil, fmt.Errorf("decode DataHint: %v", err)
  1737  	}
  1738  	var dd store.DataDescriptor
  1739  	err = json.Unmarshal(b, &dd)
  1740  	if err != nil {
  1741  		return nil, fmt.Errorf("unmarshal DataHint: %v", err)
  1742  	}
  1743  	if dd.Descriptor != dataDescriptorCommentDel {
  1744  		return nil, fmt.Errorf("unexpected data descriptor: got %v, want %v",
  1745  			dd.Descriptor, dataDescriptorCommentDel)
  1746  	}
  1747  
  1748  	// Decode data
  1749  	b, err = base64.StdEncoding.DecodeString(be.Data)
  1750  	if err != nil {
  1751  		return nil, fmt.Errorf("decode Data: %v", err)
  1752  	}
  1753  	digest, err := hex.DecodeString(be.Digest)
  1754  	if err != nil {
  1755  		return nil, fmt.Errorf("decode digest: %v", err)
  1756  	}
  1757  	if !bytes.Equal(util.Digest(b), digest) {
  1758  		return nil, fmt.Errorf("data is not coherent; got %x, want %x",
  1759  			util.Digest(b), digest)
  1760  	}
  1761  	var c comments.CommentDel
  1762  	err = json.Unmarshal(b, &c)
  1763  	if err != nil {
  1764  		return nil, fmt.Errorf("unmarshal CommentDel: %v", err)
  1765  	}
  1766  
  1767  	return &c, nil
  1768  }
  1769  
  1770  func convertCommentVoteFromBlobEntry(be store.BlobEntry) (*comments.CommentVote, error) {
  1771  	// Decode and validate data hint
  1772  	b, err := base64.StdEncoding.DecodeString(be.DataHint)
  1773  	if err != nil {
  1774  		return nil, fmt.Errorf("decode DataHint: %v", err)
  1775  	}
  1776  	var dd store.DataDescriptor
  1777  	err = json.Unmarshal(b, &dd)
  1778  	if err != nil {
  1779  		return nil, fmt.Errorf("unmarshal DataHint: %v", err)
  1780  	}
  1781  	if dd.Descriptor != dataDescriptorCommentVote {
  1782  		return nil, fmt.Errorf("unexpected data descriptor: got %v, want %v",
  1783  			dd.Descriptor, dataDescriptorCommentVote)
  1784  	}
  1785  
  1786  	// Decode data
  1787  	b, err = base64.StdEncoding.DecodeString(be.Data)
  1788  	if err != nil {
  1789  		return nil, fmt.Errorf("decode Data: %v", err)
  1790  	}
  1791  	digest, err := hex.DecodeString(be.Digest)
  1792  	if err != nil {
  1793  		return nil, fmt.Errorf("decode digest: %v", err)
  1794  	}
  1795  	if !bytes.Equal(util.Digest(b), digest) {
  1796  		return nil, fmt.Errorf("data is not coherent; got %x, want %x",
  1797  			util.Digest(b), digest)
  1798  	}
  1799  	var cv comments.CommentVote
  1800  	err = json.Unmarshal(b, &cv)
  1801  	if err != nil {
  1802  		return nil, fmt.Errorf("unmarshal CommentVote: %v", err)
  1803  	}
  1804  
  1805  	return &cv, nil
  1806  }