github.com/decred/politeia@v1.4.0/politeiad/cmd/legacypoliteia/proposal.go (about)

     1  // Copyright (c) 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 main
     6  
     7  import (
     8  	"encoding/hex"
     9  	"encoding/json"
    10  	"fmt"
    11  	"os"
    12  	"path/filepath"
    13  
    14  	backend "github.com/decred/politeia/politeiad/backendv2"
    15  	"github.com/decred/politeia/politeiad/plugins/comments"
    16  	"github.com/decred/politeia/politeiad/plugins/pi"
    17  	"github.com/decred/politeia/politeiad/plugins/ticketvote"
    18  	"github.com/decred/politeia/politeiad/plugins/usermd"
    19  )
    20  
    21  // proposal contains the full contents of a tstore proposal.
    22  type proposal struct {
    23  	RecordMetadata backend.RecordMetadata
    24  
    25  	// Files includes the proposal index file and image attachments.
    26  	Files []backend.File
    27  
    28  	// The following fields are converted into backend files before being
    29  	// imported into tstore.
    30  	//
    31  	// The VoteMetadata will only exist for RFPs and RFP submissions.
    32  	ProposalMetadata pi.ProposalMetadata
    33  	VoteMetadata     *ticketvote.VoteMetadata
    34  
    35  	// The following fields are converted into backend metadata streams before
    36  	// being imported into tstore.
    37  	//
    38  	// A public proposal will only have one status change returned. The status
    39  	// change of when the proposal was made public.
    40  	//
    41  	// An abandoned proposal will have two status changes returned. The status
    42  	// change from when the proposal was made public and the status change from
    43  	// when the proposal was marked as abandoned.
    44  	//
    45  	// All other status changes are not public data and thus will not have been
    46  	// included in the legacy git repo.
    47  	UserMetadata  usermd.UserMetadata
    48  	StatusChanges []usermd.StatusChangeMetadata
    49  
    50  	// comments plugin data. These fields may be nil depeneding on the proposal.
    51  	//
    52  	// These fields are imported into tstore as plugin data blobs.
    53  	CommentAdds  []comments.CommentAdd
    54  	CommentDels  []comments.CommentDel
    55  	CommentVotes []comments.CommentVote
    56  
    57  	// ticketvote plugin data. These fields may be nil depending on the proposal,
    58  	// i.e. abandoned proposals will not have ticketvote data.
    59  	//
    60  	// These fields are imported into tstore as plugin data blobs.
    61  	AuthDetails *ticketvote.AuthDetails
    62  	VoteDetails *ticketvote.VoteDetails
    63  	CastVotes   []ticketvote.CastVoteDetails
    64  }
    65  
    66  // isRFP returns whether the proposal is an RFP. RFPs will have
    67  // their VoteMetadata LinkBy field set.
    68  func (p *proposal) isRFP() bool {
    69  	return p.VoteMetadata != nil && p.VoteMetadata.LinkBy > 0
    70  }
    71  
    72  // isRFPSubmission returns whether the proposal is an RFP submission. RFP
    73  // submissions will have their VoteMetadata LinkTo field set.
    74  func (p *proposal) isRFPSubmission() bool {
    75  	return p.VoteMetadata != nil && p.VoteMetadata.LinkTo != ""
    76  }
    77  
    78  // writeProposal writes a proposal to disk.
    79  func writeProposal(legacyDir string, p proposal) error {
    80  	fp := proposalPath(legacyDir, p.RecordMetadata.Token)
    81  	b, err := json.Marshal(p)
    82  	if err != nil {
    83  		return err
    84  	}
    85  	return os.WriteFile(fp, b, filePermissions)
    86  }
    87  
    88  // readProposal reads a proposal from disk.
    89  func readProposal(legacyDir, legacyToken string) (*proposal, error) {
    90  	fp := proposalPath(legacyDir, legacyToken)
    91  	b, err := os.ReadFile(fp)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  	var p proposal
    96  	err = json.Unmarshal(b, &p)
    97  	if err != nil {
    98  		return nil, err
    99  	}
   100  	return &p, nil
   101  }
   102  
   103  // proposalExists returns whether the proposal exists on disk.
   104  func proposalExists(legacyDir, legacyToken string) (bool, error) {
   105  	fp := proposalPath(legacyDir, legacyToken)
   106  	if _, err := os.Stat(fp); err != nil {
   107  		if os.IsNotExist(err) {
   108  			return false, nil
   109  		}
   110  		return false, err
   111  	}
   112  	return true, nil
   113  }
   114  
   115  // proposalPath returns the file path for a proposal in the legacy directory.
   116  func proposalPath(legacyDir, legacyToken string) string {
   117  	return filepath.Join(legacyDir, legacyToken+".json")
   118  }
   119  
   120  // verifyProposal performs basic sanity checks on the converted proposal data.
   121  // These checks should be run prior to
   122  func verifyProposal(p proposal) error {
   123  	// Verify that required data is present. Plugin
   124  	// data like comment and ticketvote plugin data
   125  	// will not be present on all proposal so is not
   126  	// checked.
   127  	switch {
   128  	case p.RecordMetadata.Token == "":
   129  		return fmt.Errorf("record metadata not found")
   130  	case len(p.Files) == 0:
   131  		return fmt.Errorf("no files found")
   132  	case p.ProposalMetadata.Name == "":
   133  		return fmt.Errorf("proposal metadata not found")
   134  	case p.UserMetadata.UserID == "":
   135  		return fmt.Errorf("user metadata not found")
   136  	case len(p.StatusChanges) == 0:
   137  		return fmt.Errorf("status changes not found")
   138  	}
   139  
   140  	// Perform checks that are dependent on the record status
   141  	switch p.RecordMetadata.Status {
   142  	case backend.StatusArchived:
   143  		// Archived proposals will have two status changes
   144  		// and no vote data.
   145  		if len(p.StatusChanges) != 2 {
   146  			return fmt.Errorf("invalid status changes count")
   147  		}
   148  		if p.StatusChanges[0].Status != uint32(backend.StatusPublic) {
   149  			return fmt.Errorf("invalid status change")
   150  		}
   151  		if p.StatusChanges[1].Status != uint32(backend.StatusArchived) {
   152  			return fmt.Errorf("invalid status change")
   153  		}
   154  		if p.AuthDetails != nil {
   155  			return fmt.Errorf("auth details invalid")
   156  		}
   157  		if p.VoteDetails != nil {
   158  			return fmt.Errorf("vote details invalid")
   159  		}
   160  		if len(p.CastVotes) != 0 {
   161  			return fmt.Errorf("cast votes invalid")
   162  		}
   163  
   164  	case backend.StatusPublic:
   165  		// All non-archived proposals will be public, with a
   166  		// single status change, and will have the vote data
   167  		// populated.
   168  		if len(p.StatusChanges) != 1 {
   169  			return fmt.Errorf("invalid status changes count")
   170  		}
   171  		if p.StatusChanges[0].Status != uint32(backend.StatusPublic) {
   172  			return fmt.Errorf("invalid status change")
   173  		}
   174  		if p.AuthDetails == nil {
   175  			return fmt.Errorf("auth details missing")
   176  		}
   177  		if p.VoteDetails == nil {
   178  			return fmt.Errorf("vote details missing")
   179  		}
   180  		if len(p.CastVotes) == 0 {
   181  			return fmt.Errorf("cast votes missing")
   182  		}
   183  
   184  	default:
   185  		return fmt.Errorf("unknown record status")
   186  	}
   187  
   188  	return nil
   189  }
   190  
   191  // overwriteProposalFields overwrites legacy proposal fields that are required
   192  // to be changed or removed in order to be successfully imported into the
   193  // tstore backend.
   194  //
   195  // Documentation for each field that is updated is provided below and details
   196  // the specific reason for the update.
   197  func overwriteProposalFields(p *proposal, tstoreTokenB, parentTstoreTokenB []byte) error {
   198  	var (
   199  		legacyToken       = p.RecordMetadata.Token
   200  		tstoreToken       = hex.EncodeToString(tstoreTokenB)
   201  		parentTstoreToken = hex.EncodeToString(parentTstoreTokenB)
   202  	)
   203  
   204  	// All structures that contain a Token field are updated.
   205  	// The field currently contains the legacy proposal token.
   206  	// It's updated to reference the tstore proposal token.
   207  	//
   208  	// The following structures are updated:
   209  	// - backend RecordMetadata
   210  	// - usermd plugin StatusChangeMetadata
   211  	// - comments plugin CommentAdd
   212  	// - comments plugin CommentDel
   213  	// - comments plugin CommentVote
   214  	// - ticketvote plugin AuthDetails
   215  	// - ticketvote plugin VoteDetails
   216  	// - ticketvote plugin CastVoteDetails
   217  	p.RecordMetadata.Token = tstoreToken
   218  
   219  	for i, v := range p.StatusChanges {
   220  		v.Token = tstoreToken
   221  		p.StatusChanges[i] = v
   222  	}
   223  	for i, v := range p.CommentAdds {
   224  		v.Token = tstoreToken
   225  		p.CommentAdds[i] = v
   226  	}
   227  	for i, v := range p.CommentDels {
   228  		v.Token = tstoreToken
   229  		p.CommentDels[i] = v
   230  	}
   231  	for i, v := range p.CommentVotes {
   232  		v.Token = tstoreToken
   233  		p.CommentVotes[i] = v
   234  	}
   235  	if p.AuthDetails != nil {
   236  		p.AuthDetails.Token = tstoreToken
   237  	}
   238  	if p.VoteDetails != nil {
   239  		p.VoteDetails.Params.Token = tstoreToken
   240  	}
   241  	for i, v := range p.CastVotes {
   242  		v.Token = tstoreToken
   243  		p.CastVotes[i] = v
   244  	}
   245  
   246  	// All of the client signatures and server receipts are broken
   247  	// and are removed to avoid confusion. The most common reason
   248  	// that a signature is broken is because the message being signed
   249  	// included the legacy proposal token and we just updated the
   250  	// proposal token fields to reflect the tstore token, not the
   251  	// legacy token. The original data and coherent signatures can
   252  	// be found in the legacy proposal git repo.
   253  	//
   254  	// Other reasons that the signatures and receipts may be broken
   255  	// include:
   256  	//
   257  	// - The message being signed changed. This can be the token or
   258  	//   in some cases, like the comments plugin, additional pieces
   259  	//   of data were added to the message.
   260  	//
   261  	// - All receipts are broken because the Politeia server key
   262  	//   was switched out during the update from the git backend to
   263  	//   the tstore backend. Not sure if this was intentional or an
   264  	//   accident. There was no reason that it had to be switched so
   265  	//   it may have been an accident.
   266  	//
   267  	// - The usermd plugin UserMetadata signature is broken because
   268  	//   the merkle root of the files is different. The archived
   269  	//   proposals do not contain a proposalmetadata.json file. The
   270  	//   import process creates this file for the legacy proposals
   271  	//   and adds it to the file bundle, causing the merkle root of
   272  	//   the files to change.
   273  	p.UserMetadata.Signature = ""
   274  
   275  	for i, v := range p.StatusChanges {
   276  		v.Signature = ""
   277  		p.StatusChanges[i] = v
   278  	}
   279  	for i, v := range p.CommentAdds {
   280  		v.Signature = ""
   281  		v.Receipt = ""
   282  		p.CommentAdds[i] = v
   283  	}
   284  	for i, v := range p.CommentDels {
   285  		v.Signature = ""
   286  		v.Receipt = ""
   287  		p.CommentDels[i] = v
   288  	}
   289  	for i, v := range p.CommentVotes {
   290  		v.Signature = ""
   291  		v.Receipt = ""
   292  		p.CommentVotes[i] = v
   293  	}
   294  	for i, v := range p.CastVotes {
   295  		v.Signature = ""
   296  		v.Receipt = ""
   297  		p.CastVotes[i] = v
   298  	}
   299  	if p.AuthDetails != nil {
   300  		p.AuthDetails.Signature = ""
   301  		p.AuthDetails.Receipt = ""
   302  	}
   303  	if p.VoteDetails != nil {
   304  		p.VoteDetails.Signature = ""
   305  		p.VoteDetails.Receipt = ""
   306  	}
   307  
   308  	// The record metadata version and iteration must both
   309  	// be update to be 1. This is required because the tstore
   310  	// backend expects the versions and iterations to be
   311  	// sequential. For example, the tstore backend will error
   312  	// if it finds a record that contains an iteration 2, but
   313  	// no corresponding iteration 1. We only import the most
   314  	// recent version and iteration of a legacy proposal, so
   315  	// we must update the record metadata to reflect that.
   316  	p.RecordMetadata.Version = 1
   317  	p.RecordMetadata.Iteration = 1
   318  
   319  	// The legacy git backend token must be added to the
   320  	// ProposalMetadata. All legacy proposal will have this
   321  	// field populated. It allows clients to know that this
   322  	// is a legacy git backend proposal and to treat it
   323  	// accordingly.
   324  	p.ProposalMetadata.LegacyToken = legacyToken
   325  
   326  	// RFP submissions will have their LinkTo field of the
   327  	// ProposalMetadata populated with the token of the parent
   328  	// RFP proposal. This field will contain the parent RFP
   329  	// proposal's legacy token and needs to be updated with
   330  	// the RFP parent proposal's tstore token.
   331  	//
   332  	// This also applies to the Parent token field in the
   333  	// vote details.
   334  	if p.isRFPSubmission() {
   335  		p.VoteMetadata.LinkTo = parentTstoreToken
   336  		p.VoteDetails.Params.Parent = parentTstoreToken
   337  	}
   338  
   339  	return nil
   340  }