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 }